diff --git a/.agents/agents/other-package-agent.md b/.agents/agents/other-package-agent.md deleted file mode 100644 index bbb47a8..0000000 --- a/.agents/agents/other-package-agent.md +++ /dev/null @@ -1 +0,0 @@ -# Other package agent diff --git a/.agents/agents/sdk-consumer-setup.md b/.agents/agents/sdk-consumer-setup.md deleted file mode 100644 index 8e5066c..0000000 --- a/.agents/agents/sdk-consumer-setup.md +++ /dev/null @@ -1,34 +0,0 @@ -# sdk-consumer-setup (generic agent spec) - -## Goal - -Help a consuming repository adopt or troubleshoot `Purview.BuildSdk` correctly, without breaking existing build behaviour. - -## Workflow - -1. Confirm the SDK is imported in `Directory.Build.props`/`Directory.Build.targets` via - `` and the matching `Sdk.targets` import. -2. Check pre-import bootstrap properties are set **before** the `Sdk.props` import when they must affect - evaluation: `NamespacePrefix`, `UsePackageJsonVersion`, `RootPackageJson`. -3. If version resolution looks wrong, verify `package.json` discovery: explicit `RootPackageJson`, then CI - variables, `.git` root, or a nearby `package.json`. `UsePackageJsonVersion=Strict` fails fast instead of - silently skipping resolution. -4. If the bundled `.agents/**` content isn't appearing in the repo root, check `EnableAgentFolderInPackage` - (default `true`) and `AgentPackDestinationFolder` (default `.agents`) — the copy runs before build via - `EnsureAgentFolderInPackageTarget`. -5. For test-framework or project-shape questions, confirm the project follows repo naming and placement - conventions the SDK expects, rather than introducing bespoke structure. -6. Re-run `dotnet build` (or the repo's canonical build command) after each configuration change to confirm - the fix. - -## Constraints - -- Prefer minimal, targeted property changes over broad `Directory.Build.props` rewrites. -- Do not disable `PurviewAutoSdkPack` or `EnableAgentFolderInPackage` unless the consumer explicitly asks to - opt out. -- Do not duplicate SDK-managed properties in individual project files unless the scenario is intentionally - project-specific. - -## Related skill - -See `../skills/sdk-configuration-reference/SKILL.md` for the full property reference. diff --git a/.agents/prompts/project-harness-test.md b/.agents/prompts/project-harness-test.md deleted file mode 100644 index c5fc1c2..0000000 --- a/.agents/prompts/project-harness-test.md +++ /dev/null @@ -1,25 +0,0 @@ -# project-harness-test (generic prompt spec) - -Create or update an integration test in `src/tests/BuildSdk.IntegrationTests/` using `ProjectHarness`. - -## Required behaviour - -1. Keep scope to one behaviour (single focused scenario). -2. Use `ProjectHarness` (`Harness/ProjectHarness.cs`) to construct a throwaway project for the scenario. -3. Prefer evaluation helpers before build-log parsing: - - `GetPropertyAsync` / `GetPropertiesAsync` - - `GetItemIdentitiesAsync` / `GetProjectReferencesAsync` - - `GetPreprocessProjectAsync` when import/evaluation order matters -4. Use `BuildAsync(restore: true)` only when the scenario requires restore/build behaviour. -5. Follow TUnit conventions used in this repo: - - `[Test]` method attributes - - awaited assertions (e.g., `await Assert.That(actual).IsEqualTo(expected);`) -6. Keep setup deterministic and minimal; avoid unrelated package/config changes. - -## Suggested output structure - -- Add/modify one test file under `src/tests/BuildSdk.IntegrationTests/`. -- Include a short test comment describing **Given / When / Then** intent. -- Validate by running targeted tests first, then broader tests if needed. - -If multiple test ideas are possible, pick the one with the smallest diff that still validates the intended SDK behaviour. diff --git a/.agents/prompts/sdk-diagnose-agent-folder-copy.md b/.agents/prompts/sdk-diagnose-agent-folder-copy.md deleted file mode 100644 index e234df9..0000000 --- a/.agents/prompts/sdk-diagnose-agent-folder-copy.md +++ /dev/null @@ -1,37 +0,0 @@ -# sdk-diagnose-agent-folder-copy (generic prompt spec) - -Diagnose why the bundled `.agents/**` folder from `Purview.BuildSdk` did not appear at the expected -destination in a consuming repository. - -## Required behaviour - -1. Confirm the NuGet package actually contains `.agents/**` content (inspect the `.nupkg` if available). -2. Confirm the consuming project is packable/buildable and imports the SDK via - `Sdk.props`/`Sdk.targets`, since the copy runs in `EnsureAgentFolderInPackageTarget` before build. -3. Check `EnableAgentFolderInPackage` is not set to `false` anywhere in the build (project file, - `Directory.Build.props`, or command-line `-p:` overrides). -4. Confirm the destination folder: default is `.agents` at the repo root, overridable per-build with - `-p:AgentPackDestinationFolder=`, and the source defaults to the package-level `.agents` - folder (override with `PurviewAgentFolderSourcePath`). -5. Verify repo-root discovery succeeded: explicit `RepoRoot`, then a nearby `AGENTS.md`, then source-control - root metadata. -6. If the destination looks stale or incomplete, inspect the change-detection manifest - (`/.purview/agent-sync.cache`). It lists the files the SDK believes it already mirrored; a - matching entry with a present destination file means the sync was skipped as up to date. Delete the - manifest (or the affected destination file) to force a fresh copy. -7. Retry notices are demoted to low-importance messages, so a healthy build shows no `MSB3026` warnings. - A copy that still fails after every retry is reported as an error naming the source, destination and - OS error - search the build log for `The Purview SDK could not copy`. Temporarily set - `PurviewAgentFolderCopyRetries` / `PurviewAgentFolderCopyRetryDelayMilliseconds` to retry longer, and - `PurviewSuppressCopyRetryWarnings=false` to see every retry attempt. -8. Re-run the build and confirm the destination folder now contains the copied files (including the - generated `.gitignore` for skill/prompt/agent subfolders). - -## Suggested output - -- A short root-cause explanation (missing import, disabled flag, wrong destination override, repo-root - discovery miss, or a destination held open by another process). -- The exact command used to reproduce/verify the fix (for example - `dotnet build -p:AgentPackDestinationFolder=`). -- Confirmation that the expected files exist at the resolved destination path, plus whether the - manifest skipped the sync (in which case the content was already up to date). diff --git a/.agents/skills/dotnet-tunit/SKILL.md b/.agents/skills/dotnet-tunit/SKILL.md deleted file mode 100644 index 274d53c..0000000 --- a/.agents/skills/dotnet-tunit/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: dotnet-tunit -description: Use when creating or refactoring .NET tests with TUnit conventions (AAA pattern, naming, async assertions, and CancellationToken usage). -category: dotnet -roles: - - dotnet - - dotnet-tunit - - coding -tags: - - dotnet - - csharp - - tunit - - tests ---- - -# dotnet-tunit Skill - -Use this skill when creating or refactoring tests in a .NET codebase that uses TUnit. - -## Authoring and refactoring rules (mandatory) - -- Always use TUnit (`TUnit.Core`, `TUnit.Assertions`). -- Do not introduce xUnit, NUnit, MSTest, or FluentAssertions unless explicitly requested. -- Test methods must have `[Test]`. -- Assertion calls must be awaited. -- Prefer `await Assert.That(actual).IsEqualTo(expected);`. - -### Arrange / Act / Assert structure - -- Always use Arrange / Act / Assert. -- Always include explicit section comments in the test body: - - `// Arrange` - - `// Act` - - `// Assert` - -### Naming conventions - -- Test classes must be named `{ModuleOrClassBeingTested}Tests`. -- Test classes must be in the same namespace as the module/class being tested (identical namespace). -- Test methods must follow `{SubjectUnderTest}_{Scenario}_{Expectation}`. - - `SubjectUnderTest` is usually the method name. - - Use `ctor` for constructor-focused tests when appropriate. - -### CancellationToken rule - -- Always include `CancellationToken cancellationToken` as the last parameter when any called method in the test body supports a `CancellationToken` parameter. -- Pass that token through in the call under test (for example: `var result = await sut.ProcessAsync(cancellationToken);`). - -## Refactoring expectation - -- When refactoring existing tests, bring them into compliance with all rules above (structure, naming, and cancellation-token usage), while preserving test intent. - -## Execution reminder - -- Run relevant tests before completion. -- For execution/filtering details, use `../tunit-test-runner/SKILL.md`. diff --git a/.agents/skills/git-conventional-commits/SKILL.md b/.agents/skills/git-conventional-commits/SKILL.md deleted file mode 100644 index 6896ae6..0000000 --- a/.agents/skills/git-conventional-commits/SKILL.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -name: git-conventional-commits -description: Git workflow and conventional commit guidance. ---- - -# Git Conventional Commits Skill - -Use this skill when applying Git workflow and commit hygiene practices. - -## Rules - -- Use Conventional Commits. -- Prefer small, reviewable commits. -- Commit types include `feat`, `fix`, `docs`, `test`, `refactor`, `chore`, `build`, and `ci`. -- Do not mix unrelated changes. -- Explain validation commands before completion. diff --git a/.agents/skills/lefthook-integration/SKILL.md b/.agents/skills/lefthook-integration/SKILL.md deleted file mode 100644 index 42320ec..0000000 --- a/.agents/skills/lefthook-integration/SKILL.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -name: lefthook-integration -description: Lefthook Git hook integration. ---- - -# Lefthook Integration Skill - -Use this skill when configuring repository-local Git hook setup. - -## Rules - -- Prefer small, fast checks in pre-commit. -- Put slower checks in pre-push. -- Do not block commits with network-dependent checks. -- Keep commands cross-platform where possible. -- Document how to install and run hooks locally. diff --git a/.agents/skills/project-placement-defaults/.gitignore b/.agents/skills/project-placement-defaults/.gitignore deleted file mode 100644 index 2799754..0000000 --- a/.agents/skills/project-placement-defaults/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Ignore all files -* - -# Don't ignore directories, so Git can traverse them -!*/ - -# Keep this file -!.gitignore \ No newline at end of file diff --git a/.agents/skills/sdk-configuration-reference/.gitignore b/.agents/skills/sdk-configuration-reference/.gitignore deleted file mode 100644 index 2799754..0000000 --- a/.agents/skills/sdk-configuration-reference/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Ignore all files -* - -# Don't ignore directories, so Git can traverse them -!*/ - -# Keep this file -!.gitignore \ No newline at end of file diff --git a/.agents/skills/sdk-project-behavior-and-detection/.gitignore b/.agents/skills/sdk-project-behavior-and-detection/.gitignore deleted file mode 100644 index 2799754..0000000 --- a/.agents/skills/sdk-project-behavior-and-detection/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Ignore all files -* - -# Don't ignore directories, so Git can traverse them -!*/ - -# Keep this file -!.gitignore \ No newline at end of file diff --git a/.agents/skills/tunit-filtering/SKILL.md b/.agents/skills/tunit-filtering/SKILL.md deleted file mode 100644 index a01143f..0000000 --- a/.agents/skills/tunit-filtering/SKILL.md +++ /dev/null @@ -1,146 +0,0 @@ ---- -name: tunit-filtering -description: "Use for quick TUnit --treenode-filter patterns." ---- - -# TUnit Filtering (quick reference) - -Use this skill when you already know you need filtering syntax and want concise examples. - -For full execution and troubleshooting guidance, use `../tunit-test-runner/SKILL.md`. - -## Defaults - -- Test runner is Microsoft.Testing.Platform (MTP), not VSTest. -- Use `--treenode-filter` for narrowing tests (do not use `--filter`). -- Prefer your repository's standard full-suite test command for broad runs. - -## `--treenode-filter` - -Select tests by tree path: - -`////` - -## Tree segments - -Each segment maps to one level: - -- 1st segment: Assembly -- 2nd segment: Namespace -- 3rd segment: Class -- 4th segment: Test name - -Examples: - -- `/*/*/LoginTests/*` → all tests in class `LoginTests` -- `/*/*/*/AcceptCookiesTest` → a single test by name -- `/*/MyProject.Tests.Integration/*/*` → tests in a namespace - -## Operators and filter options - -### `*` wildcard - -Matches any value in a segment, or part of a value. - -Examples: - -- `/*/*/LoginTests*/*` -- `/*/*/MyProject.Tests.Api*/*` - -### `=` equality - -Matches an exact property value. - -Example: - -- `/*/*/*/*[Category=Unit]` - -### `!=` not equal - -Excludes a property value. - -Example: - -- `[Category!=Slow]` - -### `&` AND - -Combines multiple conditions in the same segment or property group. - -Examples: - -- `/**[(Category=Unit)&(Priority=High)]` -- `/*/*/*/*[(Category=Unit)&(Priority=High)]` - -### `|` OR - -Matches either condition, inside a single parenthesized group. - -Examples: - -- `/*/*/(LoginTests)|(SignupTests)/*` -- `/**[(Category=Unit)|(Priority=High)]` - -### `**` match-all - -Matches any path depth, but it must appear at the end of the path. - -Examples: - -- `/**` -- `/MyAssembly/**` - -## Property filtering - -You can filter on custom properties in the last segment using `[...]`. - -Examples: - -- `/*/*/*/*[Category=Unit]` -- `/*/*/*/*[Owner=*Team-Backend*]` -- `/*/*/*/*[Category!=Slow]` - -## Important rules - -- Only one property group `[...]` is allowed per path segment. -- If you need multiple property conditions, combine them inside the same brackets with `&` or `|`. -- Separate brackets like `[Category=Smoke]|[Priority=High]` are not valid. -- `**` must be at the end; `/**/Path` is not allowed. - -## Common examples - -- All tests: `/*/*/*/*` -- All unit tests: `/*/*/*/*[Category=Unit]` -- High-priority unit tests: `/*/*/*/*[(Category=Unit)&(Priority=High)]` -- Integration tests with priority: `/*/MyProject.Tests.Integration/*/*[Priority=Critical]` - -## `dotnet test` note - -TUnit does not use the usual VSTest `--filter` syntax. Use `--treenode-filter` instead. - -Preferred: `dotnet test --treenode-filter "..."` - -Compatibility form (older SDKs): `dotnet test -- --treenode-filter "..."` - -## Quick starts - -Use path-like tree-node filters for common flows: - -- `/*/*/*/*/` — all tests -- `/*/*/*/MyFeatureTests/*` — class-scoped -- `/*/*/*/*/MyScenario*` — name pattern - -## Zero-tests troubleshooting - -If zero tests run: - -1. Verify `--treenode-filter` is used (not `--filter`). -2. Try `dotnet test --treenode-filter "..."` first; if needed for your SDK version, use `dotnet test -- --treenode-filter "..."`. -3. Broaden filter first (`/*/*/*/*/`), then narrow. -4. Confirm target project contains `[Test]` methods. -5. Confirm property predicates are in one bracket group (e.g. `[(A=1)&(B=2)]`). - -## See also - -- `../tunit-test-runner/SKILL.md` -- `../dotnet-tunit/SKILL.md` diff --git a/.agents/skills/tunit-test-runner/SKILL.md b/.agents/skills/tunit-test-runner/SKILL.md deleted file mode 100644 index d0a2108..0000000 --- a/.agents/skills/tunit-test-runner/SKILL.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -name: tunit-test-runner -description: >- - Run, filter, and select TUnit tests through `dotnet test`. Use this whenever - executing .NET tests in a project that depends on TUnit (built on - Microsoft.Testing.Platform / MTP, not VSTest), when `dotnet test --filter` - reports "Zero tests ran", or when tests must be narrowed by assembly, - namespace, class, test name, [Category], or other custom properties. Covers - the `--treenode-filter` path-based query syntax, its operators, the `--` - separator rules across SDK versions, and common 0-tests troubleshooting. -license: MIT ---- - -# Running TUnit tests with `dotnet test` - -Use this skill for execution, filtering, and troubleshooting. - -For test creation/refactoring conventions (AAA pattern, naming, cancellation tokens), use `../dotnet-tunit/SKILL.md`. - -## The one rule that matters most - -TUnit runs on **Microsoft.Testing.Platform (MTP)**, not VSTest. The reflexive -`dotnet test --filter "Category=X"` **does not work**: MTP silently rejects the -`--filter` flag, prints its own help text, and exits with `Zero tests ran`. That -looks like a passing-but-empty run or a config failure, but it is just an -unrecognised flag. **Never reach for `--filter` on a TUnit project.** Use -`--treenode-filter` instead. - -| Other frameworks (VSTest) | TUnit (MTP) | -| ------------------------------------------ | ---------------------------------------------------- | -| `--filter "Category=Integration"` | `--treenode-filter "/*/*/*/*[Category=Integration]"` | -| `--filter "FullyQualifiedName~LoginTests"` | `--treenode-filter "/*/*/LoginTests/*"` | -| `--filter "Name=AcceptCookiesTest"` | `--treenode-filter "/*/*/*/AcceptCookiesTest"` | - -## How to invoke it - -Prefer `dotnet test` over `dotnet run`: `dotnet test` builds and runs every -targeted TFM automatically and works against a `.csproj`, `.sln`, or `.slnx`, -whereas `dotnet run` only runs a single TFM. - -The catch is the `--` separator, which depends on the SDK: - -```bash -# Universal form — works on every SDK. Use this by default. -dotnet test -- --treenode-filter "/*/*/LoginTests/*" - -# .NET 10+ SDK only — the platform flag can be passed directly. -dotnet test --treenode-filter "/*/*/LoginTests/*" -``` - -Anything after `--` is passed through to the TUnit test runner rather than to -the `dotnet test` command itself. Flags from extension packages -(`--coverage`, `--report-trx`, `--results-directory`, etc.) **must** also sit -after the `--`: - -```bash -dotnet test --configuration Release --no-build \ - -- --treenode-filter "/*/*/*/*[Category=Unit]" --coverage --report-trx -``` - -> Run with no filter to execute everything: `dotnet test`. - -## The `--treenode-filter` syntax - -A filter is a path with four segments, optionally annotated with a property -group on any segment: - -``` -////[Property=Value] -``` - -Use `*` as a wildcard in any segment. The classic "run all tests" filter is -`/*/*/*/*` — four wildcards, one per level. - -### Operators - -| Operator | Meaning | Example | -| -------- | --------------------------------------------- | -------------------------------------- | -| `*` | Wildcard within a segment | `/*/*/LoginTests*/*` | -| `=` | Property equals (exact) | `/*/*/*/*[Category=Unit]` | -| `!=` | Property not equal (exclude) | `/*/*/*/*[Category!=Slow]` | -| `&` | AND — within one segment / property group | `/**[(Category=Unit)&(Priority=High)]` | -| `\|` | OR — within one segment / property group | `/*/*/(LoginTests)\|(SignupTests)/*` | -| `**` | Match any path depth (must be at the **end**) | `/MyAssembly/**` | - -### Two grammar rules that are easy to get wrong - -1. **`&` and `|` operate _inside a single segment or property group_, and each - side must be wrapped in parentheses.** They do not join two complete paths. -2. **Only one property group `[...]` is allowed per path segment.** Combine - conditions _inside_ the single bracket — do not chain brackets. -3. **`**` must terminate the path.** `/MyAssembly/**` is valid; `/**/Class/*` - is not. - -## Troubleshooting: "Zero tests ran" / 0 tests discovered - -Check, in order: - -1. **`--filter` was used instead of `--treenode-filter`.** This is the most common cause. -2. **`Microsoft.NET.Test.Sdk` is still referenced.** It conflicts with the TUnit MTP platform. -3. **TUnit package missing.** Ensure ``. -4. **Missing `[Test]` attribute** or unsupported method shape. -5. **Wrong `OutputType`.** A `hostfxr.dll could not be found` error means the project needs `Exe`. -6. **Bad filter shape.** Remember the path has exactly four segments. - -## References - -- TUnit — Test Filters: https://tunit.dev/docs/execution/test-filters/ -- TUnit — Troubleshooting & FAQ: https://tunit.dev/docs/troubleshooting/ -- TUnit — CI/CD pipelines: https://tunit.dev/docs/examples/tunit-ci-pipeline/ -- TUnit — Explicit tests: https://tunit.dev/docs/writing-tests/explicit/ -- MTP graph-query filtering spec: https://github.com/microsoft/testfx/blob/main/docs/mstest-runner-graphqueryfiltering/graph-query-filtering.md diff --git a/.config/purview-build.json b/.config/purview-build.json index f353eb9..d738464 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -6,14 +6,12 @@ "TestFilter": "/*/*/*/*[Category=Unit]/" }, "PackValidation": { - // Explicit because Purview.Build 0.5.3 defaults RequiredCompilerFlags to empty. Earlier - // versions forced this check on and could not be configured off, so these packages have - // always been validated against it; declaring it keeps that behaviour. - "RequiredCompilerFlags": ["optimization=release"], + "RequiredCompilerFlags": [ + "optimization=release" + ], "RequireSymbolPackage": false, "RequiredContent": { "purview.buildsdk": [ - ".agents/**/*", "analyzers/dotnet/cs/Purview.BuildSdk.Analyzers.dll", "analyzers/dotnet/cs/Purview.BuildSdk.CodeFixers.dll", "purview-logo-light.png", @@ -24,10 +22,21 @@ "Sdk/Sdk.props", "Sdk/Sdk.targets", "Sdk/Targets/VersionDetection.targets" + ], + "purview.architecture.testing": [ + "README.md", + "lib/net10.0/Purview.Architecture.Testing.dll", + "lib/net10.0/Purview.Architecture.Testing.xml", + "content/architecture-policy.schema.json" + ], + "purview.skills.testing": [ + "README.md", + "lib/$(TFM)/Purview.Skills.Testing.dll", + "lib/$(TFM)/Purview.Skills.Testing.xml" ] } }, "Release": { "Mode": "None" } -} +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3f1a8f3..23793e5 100644 --- a/.gitignore +++ b/.gitignore @@ -663,3 +663,19 @@ sketch .tools/ + +# Agent skills installed by the skills CLI. +# +# The authored copies live at skills// and are the only tracked ones. Everything below is +# written by `npx skills add` from that folder and is reproducible from the committed +# skills-lock.json, so none of it belongs in the repository: +# +# .agents/skills/ the CLI's "universal" location, shared by most agents +# .claude/skills/ per-agent links into the universal location +# agent/skills/ ditto +# +# Note that .agents/agents/ and .agents/prompts/ are authored content and stay tracked, so the +# skills folder is ignored specifically rather than the whole of .agents/. +/.agents/skills/ +/.claude/skills/ +/agent/skills/ diff --git a/AGENTS.md b/AGENTS.md index 2db8ec7..4e0c31f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,27 +6,89 @@ Guidance for AI coding agents working in this repository. Use `.agents/` as the default location for **generic agentic information** shared across agent runtimes. -- `.agents/skills/` — canonical, cross-agent skills -- `.agents/agents/` — canonical, cross-agent agent workflow specs -- `.agents/prompts/` — canonical, cross-agent prompt specs +- `.agents/agents/` — canonical, cross-agent agent workflow specs (authored, tracked) +- `.agents/prompts/` — canonical, cross-agent prompt specs (authored, tracked) +- `.agents/skills/` — **not authored.** Skills installed from *other* sources, written by the skills CLI and gitignored; see below. Use `.github/` customization files only as VS Code/Copilot wrappers or registration points when required by tooling. +## Two skills folders, two purposes + +This distinction is the one most easily got wrong, and getting it wrong produces two copies that nothing keeps in step. + +| Folder | Holds | Written by | Tracked | +| -- | -- | -- | -- | +| `skills//` | the skills this repository **authors and publishes** | by hand | yes | +| `skills-lock.json` | the record of skills installed **from other sources** | the skills CLI | yes | +| `.agents/skills//` | skills installed from other sources (the CLI's "universal" location) | the CLI | no | +| `.claude/skills/`, `agent/skills/` | per-agent links into the universal location | the CLI | no | + +### `skills/` is published, not installed + +Every skill in `skills/` describes a product this repository ships — `Purview.BuildSdk`, `Purview.Architecture.Testing`, `Purview.Skills.Testing` — and exists so **another** repository can install it: + +```bash +npx skills add purview-dev/build-sdk +``` + +They are not installed into this repository's own agent directories. An agent working here reads them at their authored path, which is what AGENTS.md links to, and what the skills CLI clones. Installing them into `.agents/skills/` as well would put this repository's own published advice into the folder reserved for other people's, and leave two copies to drift apart. `RepositorySkillsSelfTests.AuthoredSkillsAreNotInstalledIntoTheAgentDirectories` enforces that. + +Naming follows from the same fact. Skills install into one flat directory per agent, shared with every skill a developer has installed from anywhere, so **every skill here carries the `purview-buildsdk-` prefix**. `sdk-*`, `project-*`, `test-*` and `build-*` are far too common to use unprefixed, and the loser of a collision is whichever installed second. `RepositorySkillTests.EverySkill_IsNamedForThisProduct` enforces that. + +Guidance about working *in this repository* belongs in this file, not in a skill — a skill here ships to someone else. + +### `.agents/skills/` is for skills from elsewhere + +To add one: + +```bash +just skills-add purview-dev/engineering-playbook # append '#' to pin it +``` + +Everything the CLI writes is gitignored, because it is all reproducible from the committed `skills-lock.json` with `npx skills experimental_install`. Only `.agents/skills/` is ignored, not the whole of `.agents/`, because `.agents/agents/` and `.agents/prompts/` are authored content. + +**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. + +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. + +Link to a skill by its **authored** path (`./skills//SKILL.md`). A link into `.agents/skills/` points at a gitignored file and is broken in a fresh clone; `RepositorySkillsSelfTests` fails the build if one reappears. + ## Purpose 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. -Repo-specific agent content lives under `src/src/BuildSdk/Sdk/.agents/` and is packed into the NuGet package as `.agents/**` by the standard `PurviewAutoSdkPack` `Sdk/` packaging logic. Add new skills under that path so they automatically flow into consuming repositories without hardcoding individual skill names. +**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/`. + +**Skills are authored at the repository root in `skills//SKILL.md`** (see above), and published only through the skills CLI (`npx skills`), which clones the *git* repository and only discovers a root `skills/` folder. No package carries them. + +Add a new skill under `skills/` and name it `purview-buildsdk-`. Nothing packs them into a NuGet package — `ValidatePurviewSkills` runs before `Pack` so a malformed skill fails here, and the CLI delivers them from git. + +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, and why a property had no effect | +| [`purview-buildsdk-configuration`](./skills/purview-buildsdk-configuration/SKILL.md) | which property controls this, and where must it be set | +| [`purview-buildsdk-project-layout`](./skills/purview-buildsdk-project-layout/SKILL.md) | where does a project go, and what is it called | +| [`purview-buildsdk-project-detection`](./skills/purview-buildsdk-project-detection/SKILL.md) | why did the SDK do that automatically | +| [`purview-buildsdk-testing`](./skills/purview-buildsdk-testing/SKILL.md) | writing TUnit tests, and running them under MTP | +| [`purview-buildsdk-architecture-tests`](./skills/purview-buildsdk-architecture-tests/SKILL.md) | adopting and debugging `Purview.Architecture.Testing` | +| [`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 | + +After editing one, run `just skills-validate` for the pack-time check and `just test` for the full rule set. -## AgentPack folder and downstream impact +There is no `Sdk/.agents/` folder any more, and the SDK writes nothing into an agent directory. + +## Sdk folder packaging and downstream impact **Hard requirement:** This SDK must pack the contents of `Sdk/` into the NuGet package so that downstream consumers of `Purview.BuildSdk` receive the same `Sdk/**` files. The `PurviewAutoSdkPack` feature is the mechanism that delivers this for standard consuming projects. Do not implement `Sdk/` packaging only for the `BuildSdk` project itself. For packable projects, `PurviewAutoSdkPack` (default `true`) automatically adds `Sdk/**/*` as `None` items with `Pack="true"` and `Visible="true"`, mapping each file to the correct location in the package: -- `Sdk/.agents/**` → `.agents/**` - `Sdk/.github/**` → `.github/**` - `Sdk/build/**` → `build/**` - `Sdk/buildTransitive/**` → `buildTransitive/**` @@ -34,26 +96,172 @@ For packable projects, `PurviewAutoSdkPack` (default `true`) automatically adds - `Sdk/*.md`, `Sdk/*.png`, `Sdk/*.jpg`, etc. → package root - everything else under `Sdk/` → `Sdk/` -The `BuildSdk.csproj` itself is an MSBuild SDK, so it disables `PurviewAutoSdkPack` and explicitly packs its `Sdk/` contents instead. This is an exception for the SDK project only; every other project that consumes this SDK relies on `PurviewAutoSdkPack` to ship its `Sdk/` folder. Consuming repositories that use this SDK get the bundled agent folder mirrored into `$(AgentPackDestinationFolder)/` (default `.agents/`) before build when `EnableAgentFolderInPackage` is `true` (default). +The `BuildSdk.csproj` itself is an MSBuild SDK, so it disables `PurviewAutoSdkPack` and explicitly packs its `Sdk/` contents instead. This is an exception for the SDK project only; every other project that consumes this SDK relies on `PurviewAutoSdkPack` to ship its `Sdk/` folder. + +### The agent-folder mirror was removed + +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. + +`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. + +Any edit, addition, or deletion in the repository-root `skills/` folder changes the contents delivered to every repository that consumes this SDK. + +Tests live in `src/tests/BuildSdk.IntegrationTests/RepositoryFileSyncTests.cs` (bootstrap writes and the style policy that reads them). +## Explicit skill installation + +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`: + +```bash +dotnet msbuild -t:ListPurviewSkills # diagnostics only, installs nothing +dotnet msbuild -t:InstallPurviewSkills # delegates to the skills CLI +``` + +`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 (`npx skills`) 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 `npx skills experimental_install`. `PurviewSkillsAgents` (default `*`) narrows the target agents. + +### Package runners and the repository root + +Two invariants govern how the CLI is launched, and both are load-bearing. + +**Any package runner, defaulting to npx.** The CLI is published to npm and each runner spells "run this npm package" differently. `PurviewSkillsRunner` (default `npx`, because npx ships with Node itself) 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. + +`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. + +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: -The mirror is change-aware and lock tolerant: `SyncPurviewRepositoryFiles` (inline task in `Sdk/Sdk.targets`) skips unchanged files via the `/.purview/agent-sync.cache` manifest, stages every write into a temporary file next to the destination before renaming it into place, treats "another project already wrote identical content" as success, retries quietly, and escalates only after `PurviewAgentFolderCopyRetries` attempts (`PurviewAgentFolderCopyFailureAsError=false` downgrades that to a warning). `PurviewSuppressCopyRetryWarnings` (default `true`) demotes the built-in copy task's retry notice (`MSB3026`) to a message, which is set in `Sdk.targets` so it can be toggled from the project file. The same task performs the `.editorconfig`/`global.json` bootstraps (`PurviewRepoBootstrapMode`: `IfMissing`/`Always`/`WarnOnDrift`/`Never`). `PurviewAgentFolderSourcePath` defaults to the package-level `.agents` folder and is deliberately defined in `Sdk/Sdk.props` — defining it in `Sdk/Props/Defaults.props` resolves `$(MSBuildThisFileDirectory)` to `Sdk/Props/` and silently breaks the packaged layout. +- **It cannot be an MSBuild intrinsic.** `GetDirectoryNameOfFileAbove` matches *files only* and returns nothing for `.git/`, `.github/`, `.agents/` or `.claude/` — four of the nine markers. Verified, not assumed. +- **Two passes.** The first marker in the list is definitive and is searched all the way up (`.git/` *is* the repository root, so a monorepo sub-package does not win). Every other marker uses nearest-ancestor-wins, with list order breaking ties inside one directory. Searching them all to the top would let a stray `.claude/` in a developer's home directory outrank the real root. +- **The home directory and everything above it are never candidates.** Without that bound, a project outside any repository resolves to `~` and installs into it. A repository below the home directory is unaffected. -During packaging, the SDK injects 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# This file is mirrored from a NuGet package, so ignore it too\n/.gitignore`, so the copied folder is ignored by Git in consuming repositories while keeping the folder structure discoverable. At mirror time the SDK also writes a generated `/.gitignore` listing every mirrored file by path, which covers the folders it does not wholly own (`.agents/agents/`, `.agents/prompts/`) without hiding files the repository authored beside them. +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. -Git only ignores untracked paths, so a mirrored file that is already tracked is rewritten by every upgrade that changes it and shows up as a local modification. The SDK cannot untrack it, so it reports `PurviewTrackedMirroredFile` (naming the paths and the `git rm --cached` command) on a build that mirrored that file; `DisableMirroredFileTrackingCheck=true` opts out. The check shells out to `git ls-files` only when something was mirrored, so an up-to-date build spawns no process. +`ListPurviewSkills` reports runner availability but **never** errors on its absence — resolving and reporting needs no Node tooling at all. -Any edit, addition, or deletion in `src/src/BuildSdk/Sdk/.agents/` therefore changes the contents delivered to every repository that consumes this SDK. +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. -Tests for this feature live in `src/tests/BuildSdk.IntegrationTests/AgentPackFolderTests.cs` (packaging) and `src/tests/BuildSdk.IntegrationTests/RepositoryFileSyncTests.cs` (repository mirroring, change detection, retry and failure behaviour). +### 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 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), +- `name` matches the folder name, because the CLI installs by frontmatter name while a human reads the folder, and +- every `references/...` path the body links to exists. + +Failures are reported as `PRSKL0005`, one per problem. Every authored skill is checked: nothing is packed any more, so there is no per-project subset to narrow to. Prose quality and whether an agent obeys a skill are deliberately **not** checked — a build can only check that a skill is well formed. + +Tests live in `src/tests/BuildSdk.IntegrationTests/PurviewSkillValidationTests.cs`. + +## Purview.Skills.Testing + +`src/src/SkillsTesting/` ships a third package: the Agent Skills conformance rule set, shaped exactly like `Purview.Architecture.Testing` and for the same reason — the rules are defined once rather than reimplemented in every repository that authors skills. + +Three layers, deliberately separated: + +- **`SkillManifestReader` / `SkillCatalogue`** — read `skills/*/SKILL.md`. No MSBuild and no test-framework types. The frontmatter parser covers the subset of YAML the specification defines rather than taking a YAML dependency, because this assembly is referenced by a consuming repository's test project and a transitive YAML parser is a dependency conflict waiting to happen for a format whose whole surface is six fields. Anything it cannot parse becomes a reported structural error, never a silent drop. +- **`SkillPolicyEvaluator`** — the rules, returning typed `SkillViolation` lists. It takes a `SkillCatalogue`, so a rule is tested against a fixture folder rather than a repository on disk (`SkillsTesting.UnitTests`). +- **`PurviewSkillTests`** — a TUnit base class, one `[Test]` per rule. + +A consuming repository adds `[InheritsTests] public sealed class SkillTests : PurviewSkillTests;`. **`[InheritsTests]` is load-bearing** for the same reason it is in the architecture package: TUnit does not walk base types during discovery, so without it the class compiles, the run is green and nothing is validated. `InheritedBaseClassTests` proves discovery works, against a temporary fixture so the assertion is about discovery rather than about whatever is authored in `skills/` today. + +The constraints come from : `name` is 1–64 characters of lowercase `a-z0-9` and single hyphens matching the folder name; `description` is 1–1024 characters; `license`, `compatibility` (≤500), `metadata` (string→string) and `allowed-tools` are the only other defined fields; `SKILL.md` should stay under 500 lines with the detail in `references/`. + +Two rules are worth knowing the reasoning for. A repository with **no** `skills/` folder passes everything, because authoring skills is optional — but an **empty** folder fails `skills-discovery`, because every other rule is vacuous against an empty catalogue, exactly as `graphDiscovery` guards the architecture rules. And `skill-bundled-resources` rejects a link that resolves *outside* the skill folder, not only a missing one: only the skill's own folder is installed, so such a link works in this repository and nowhere else. + +### The repository-root walk exists twice + +`SkillRepositoryRoot` is a managed port of the SDK's `FindPurviewRepositoryRoot` inline task, with the same `PurviewRepositoryRootMarkers` defaults. It has to be: the build installs skills into the root MSBuild resolves, and the tests validate the skills in the root the managed port resolves, so a divergence means validating one folder while installing another — and neither side would report anything. + +`SkillRepositoryRootParityTests` in `BuildSdk.IntegrationTests` is what catches that. It drives the real MSBuild target through `ProjectHarness` and the real managed resolver over the same folder and compares both the root and the matched marker, for every default marker and for the definitive-marker and nearest-marker cases. Each implementation is independently tested against its own expectations, so both would stay green while drifting apart; only the comparison catches it. + +`RepositorySkillTests` is this repository's self-validation. It deliberately has **no `RepositoryRoot` override**, so an ordinary test run exercises the default resolution, and `SkillsFolder_ResolvesToThisRepository` asserts that it landed here — the inherited rules would otherwise all pass vacuously against a catalogue that was never found. + +Tests live in `src/tests/SkillsTesting.UnitTests/`, with the repository-level ones in `src/tests/BuildSdk.IntegrationTests/RepositorySkillTests.cs`, `SkillRepositoryRootParityTests.cs` and `RepositorySkillsSelfTests.cs`. + +### Recommending skills + +Installation is explicit, but **recommendation runs during an ordinary build** — it is the one skill feature that does. `RecommendPurviewSkillsOnBuild` (`AfterTargets="Build"`) delegates to `RecommendPurviewSkills`, which can also be invoked by name: + +```bash +dotnet msbuild -t:RecommendPurviewSkills +``` + +`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. + +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. + +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. + +Four things keep it quiet, and all of them are load-bearing: + +- design-time builds (`DesignTimeBuild`), CI builds (`ContinuousIntegrationBuild`/`TF_BUILD`/`GITHUB_ACTIONS`/`CI` unless `PurviewSkillRecommendationsInCI=true`), non-default TFM passes and `EnablePurviewSkillRecommendations=false` are all skipped outright; +- a fingerprint stamp (`/.purview/skill-recommendations.cache`) means a given state is reported once per repository rather than once per project and again on every rebuild — the explicit target ignores the stamp; +- missing and drifted are warnings (actionable in one command), while unverifiable and orphaned are messages, so no amount of skill advice can break a build using `TreatWarningsAsErrors`; and +- a correctly installed set produces no output at all. + +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. + +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. + +## Purview.Architecture.Testing + +`src/src/ArchitectureTesting/` ships a second package: the architecture rule engine other repositories consume, so the policy is defined once rather than copied into every repo's test project. + +Three layers, deliberately separated: + +- **`ArchitecturePolicy` / `ProjectGraph`** — read `.config/architecture-policy.json` and the `.csproj` graph. No MSBuild or test-framework types. +- **`ArchitecturePolicyEvaluator`** — the rules, returning typed `ArchitectureViolation` lists per rule. Takes an in-memory graph, so a rule is tested as a three-project fixture rather than a repository on disk (`ArchitectureTesting.UnitTests`). +- **`PurviewArchitectureTests`** — a TUnit base class wrapping the evaluator, one `[Test]` per rule. + +A consuming repository adds a policy file and `[InheritsTests] public sealed class ArchitectureTests : PurviewArchitectureTests;`. + +**`[InheritsTests]` is load-bearing.** TUnit does not walk base types during discovery, so without it the class compiles, the run is green and nothing is enforced. `InheritedBaseClassTests` exists to prove discovery works — it is the test that would catch that regression, and it runs against a temporary fixture repository because this repo's own projects (an MSBuild SDK and Roslyn components) are not described by the responsibility catalogue. + +The policy JSON Schema lives at the repository root (`architecture-policy.schema.json`), embedded as a resource for offline validation and published at its raw GitHub URL for editors — the same arrangement `Purview.Build` uses for `purview-build.json`. + +This project sets `RootNamespace`, `AssemblyName` and `AutoIncludeUsings=false`, because the package namespace deliberately sits outside the repository's `Purview.BuildSdk` prefix; without that the SDK emits a `global using` for a namespace this assembly does not contain. ## Repository map +- `src/src/ArchitectureTesting/` — packable architecture rule engine (`Purview.Architecture.Testing`) +- `src/src/SkillsTesting/` — packable agent-skill rule engine (`Purview.Skills.Testing`) - `src/src/BuildSdk/` — packable MSBuild SDK package (`Purview.BuildSdk`) - `src/src/Analyzers/` — Roslyn analyzer/source-generator assembly - `src/src/CodeFixers/` — Roslyn code-fix assembly (`Purview.BuildSdk.CodeFixers`) - `src/tests/Analyzers.UnitTests/` — analyzer-focused unit tests - `src/tests/Analyzers.IntegrationTests/` — analyzer integration tests (Roslyn end-to-end analyzer/suppressor/code-fix behavior) +- `src/tests/ArchitectureTesting.UnitTests/` — architecture rule tests, against in-memory and fixture graphs +- `src/tests/SkillsTesting.UnitTests/` — skill rule tests, against fixture skills folders - `src/tests/BuildSdk.IntegrationTests/` — integration harness validating SDK behaviour +- `skills/` — the agent skills this repository authors and publishes - `src/BuildSdk.slnx` — solution entry point ## Test project placement and namespace conventions @@ -65,6 +273,12 @@ When adding or changing tests in this repository: - Keep **analyzer integration tests** (behavior spanning analyzer diagnostics, suppressors, and code fixes) in `src/tests/Analyzers.IntegrationTests/`. - Keep **SDK integration harness tests** in `src/tests/BuildSdk.IntegrationTests/`. +- Keep **architecture rule tests** in `src/tests/ArchitectureTesting.UnitTests/`, against in-memory + or fixture graphs. +- Keep **skill rule tests** in `src/tests/SkillsTesting.UnitTests/`, against fixture skills folders. +- Keep tests that assert something about **this repository** — its own skills, its own arrangement — + in `src/tests/BuildSdk.IntegrationTests/`, not in the rule-engine unit test projects. A rule + engine's tests must stay independent of what this repository happens to contain today. Namespace expectations: @@ -72,6 +286,8 @@ Namespace expectations: - `Analyzers.IntegrationTests` sources use `Purview.BuildSdk.Analyzers` - `BuildSdk.IntegrationTests` sources use `Purview.BuildSdk` +- `ArchitectureTesting.UnitTests` sources use `Purview.Architecture.Testing.UnitTests` +- `SkillsTesting.UnitTests` sources use `Purview.Skills.Testing.UnitTests` Do not mix analyzer unit/integration tests in the same project unless explicitly requested. @@ -85,6 +301,8 @@ Prefer `just` tasks: - `just lint-check` - `just lint-fix` - `just pack` +- `just skills-validate` — the pack-time check over the authored skills +- `just skills-add ` — install a skill from another repository into `.agents/skills/` `dotnet` fallback uses `src/BuildSdk.slnx` and `Release`. @@ -118,11 +336,9 @@ requirement, not a preference: - The harness (`ProjectHarness`) already creates throwaway projects under `Path.GetTempPath()` — keep it that way; never introduce fixed absolute or Windows-style paths. -For filtering syntax and troubleshooting, see: - -- [`tunit-test-runner` skill](./.agents/skills/tunit-test-runner/SKILL.md) -- [`dotnet-tunit` skill](./.agents/skills/dotnet-tunit/SKILL.md) -- [`tunit-filtering` skill](./.agents/skills/tunit-filtering/SKILL.md) +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). ### Integration harness for complex validation @@ -151,14 +367,18 @@ Supporting files: ## Release and commit workflow -- Versioning is driven by `package.json` -- Use existing commit conventions: - - [`git-conventional-commits` skill](./.agents/skills/git-conventional-commits/SKILL.md) - - [`lefthook-integration` skill](./.agents/skills/lefthook-integration/SKILL.md) +- Versioning is driven by `package.json`. +- Use [Conventional Commits](https://www.conventionalcommits.org/): `feat`, `fix`, `docs`, `test`, + `refactor`, `chore`, `build`, `ci`. Prefer small, reviewable commits, and do not mix unrelated + changes in one. +- State the validation commands you ran before declaring a change done. +- Git hooks are configured in `.config/lefthook.yml`. Keep fast checks in `pre-commit` and slower + ones in `pre-push`, keep the commands cross-platform, and never make a commit depend on the + network. ## Practical guardrails for agents - Validate changes with targeted tests first, then broader suite as needed. - When touching test behaviour, verify with MTP/TUnit-compatible invocation. - Prefer linking users to existing docs over duplicating long explanations in chat. -- Keep generic guidance in `.agents/`; keep `.github/` copies thin and referential. +- Keep guidance about working in this repository in this file; keep `.github/` copies thin and referential. diff --git a/Directory.Packages.props b/Directory.Packages.props index 5570854..a2575fb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,9 @@ + + + diff --git a/Justfile b/Justfile index c541a41..ba77fb0 100644 --- a/Justfile +++ b/Justfile @@ -6,7 +6,7 @@ artifacts_folder := "./artifacts" pipeline_tool := "dotnet purview-build" -current_version := `node -p "require('./package.json').version"` +current_version := `bun -p "require('./package.json').version"` [private] default: @@ -19,22 +19,19 @@ ensure-pipeline-tool: # Run the PR pipeline (restore, build, lint, tests) [group('Pipeline')] -pipeline-pr *args: - just ensure-pipeline-tool +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: - just ensure-pipeline-tool +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: - just ensure-pipeline-tool +pipeline-release *args: ensure-pipeline-tool echo "Running release pipeline..." {{ pipeline_tool }} --Release:Mode=NuGet {{ args }} @@ -43,23 +40,19 @@ pipeline-release *args: # 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: - just ensure-pipeline-tool - just lint-fix +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: - just ensure-pipeline-tool +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: - just ensure-pipeline-tool +pipeline-pack-validate *args: ensure-pipeline-tool echo "Running pack + validate pipeline..." {{ pipeline_tool }} --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} @@ -112,6 +105,45 @@ 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 }} +# 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 src/src/BuildSdk/BuildSdk.csproj -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 src/src/BuildSdk/BuildSdk.csproj -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 src/src/BuildSdk/BuildSdk.csproj -t:ListPurviewSkills -v:n {{ args }} + +# Report missing, drifted or unverifiable skills without installing anything +[group('Agent Skills')] +skills-check *args: + dotnet msbuild src/src/BuildSdk/BuildSdk.csproj -t:RecommendPurviewSkills -v:n {{ args }} + # Format the code with CSharpier [group('Utilities')] lint-fix: diff --git a/README.md b/README.md index dc94e86..eb3d1de 100644 --- a/README.md +++ b/README.md @@ -182,18 +182,46 @@ The `templates/` folder contains ready-to-copy starter files for new repos: | `.gitattributes` | Line-ending normalisation for .cs, .json, .yml, etc. | | `.config/dotnet-tools.json` | CSharpier tool manifest | -The package also ships bundled agent content under `.agents/**`. During build, the SDK mirrors it into the consuming repository's `.agents/` folder by default so compatible coding agents can discover repository-aware guidance automatically. The SDK also injects a `.gitignore` file into each second-level agent folder 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`, so the copied folder is ignored by Git while keeping the folder structure discoverable. +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). 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. -The mirror is change-aware: file fingerprints and content hashes are recorded in -`.purview/agent-sync.cache` at the repository root, so unchanged content is skipped and repeat builds -touch no files. Every write is staged into a temporary file and renamed into place, and because all -projects in a solution share these destinations the retries are silent — copy retry notices (`MSB3026`) -are demoted to low-importance messages while a copy that still fails after every retry is reported as an -error. See [Agent Folder](docs/wiki/Agent-Folder.md) and +`.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 +place, an existing file is never overwritten unless asked, retries are silent (`MSB3026` is demoted +to a low-importance message) and only a copy that still fails after every retry is reported. See [Repository Bootstrap](docs/wiki/Repository-Bootstrap.md) for the retry and failure properties. +### 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. + +```bash +npx 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 +``` + +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. Set `EnablePurviewSkillRecommendations=false` to +turn the build-time reporting off; it is already silent on CI, during design-time builds, and when +everything is installed and compatible. + +The CLI is launched through a package runner. `PurviewSkillsRunner` defaults to `npx` — which ships +with Node itself — and also accepts `bunx`, `bun`, `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). + --- ## Configuration reference @@ -251,6 +279,8 @@ Version detection logging is disabled by default. Set `VersionDetectionLogEnable | `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. | +| `AssemblyIdentifierClassName` | `Assembly` | Overrides the name of the generated assembly identifier marker class. | | `AutoIncludeUsings` | `true` | Controls SDK-added global usings for `NamespacePrefix` and `RootNamespace`. | ### Packable project defaults @@ -291,26 +321,35 @@ Non-packable projects (including web applications) default `WarnOnPackingNonPack | `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. | -### Agent folder +### Sdk folder packaging | Property | Default | Description | | -- | -- | -- | | `PurviewAutoSdkPack` | `true` | When `true`, automatically packs the `Sdk/` folder contents into the NuGet package with the correct root-level paths. Disable this for MSBuild SDK projects. | -| `EnableAgentFolderInPackage` | `true` | Mirrors the bundled `.agents/**` folder from the SDK NuGet package into the consuming repository's `.agents/` folder (or `$(AgentPackDestinationFolder)/`) before build. | -| `AgentPackDestinationFolder` | `.agents` | Repo-relative destination folder that receives the mirrored agent folder contents when `EnableAgentFolderInPackage` is `true`. | -| `PurviewAgentFolderSourcePath` | *(package-level `.agents`)* | Overrides the folder that provides the bundled `.agents` content. | -| `PurviewAgentFolderCopyRetries` | `3` | Copy attempts per file before the failure is reported. | -| `PurviewAgentFolderCopyRetryDelayMilliseconds` | `500` | Base delay between copy attempts. | -| `PurviewAgentFolderCopyFailureAsError` | `true` | When `false`, a copy that still fails after every retry is a warning instead of an error. | -| `PurviewAgentSyncManifestPath` | `/.purview/agent-sync.cache` | Overrides the change-detection manifest used to skip unchanged agent content. | -To disable bundled agent folder copying in a consuming repo, set the opt-out property before importing the SDK: +> **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. -```xml - - false - -``` +### Agent skills + +| Property | Default | Description | +| -- | -- | -- | +| `EnablePurviewSkillRecommendations` | `true` | Reports missing or version-drifted agent skills after an ordinary build. Offline; never installs; never fails the build. | +| `PurviewSkillRecommendationsInCI` | `false` | When `true`, recommendations are reported on CI builds as well. | +| `PurviewSkillRecommendationStampPath` | `/.purview/skill-recommendations.cache` | Overrides the stamp that reports each distinct skill state once per repository. | +| `PurviewSkillsLockFile` | `/skills-lock.json` | Overrides where the skills CLI's lockfile is read from. | +| `PurviewSkillsSourceFolder` | `/skills` | Repository folder holding authored skills. | +| `PurviewSkillsAgents` | `*` | Agents that `InstallPurviewSkills` installs to, and that recommended commands name. | +| `PurviewSkillsRunner` | `npx` | Package runner used to launch the skills CLI (`npx`, `bunx`, `bun`, `pnpm`, `yarn`, `deno`, `skills`, or any other). | +| `PurviewSkillsRunnerCandidates` | `npx;bunx;bun;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. | When a project is packable, the SDK treats any content under `Sdk/` as a pack target with these rules: @@ -362,6 +401,8 @@ The SDK now exports its properties via `CompilerVisibleProperty`, so analyzers a | `ExcludePurviewTelemetry` | Opt-out for `Purview.Telemetry.SourceGenerator`. | | `ExcludeMSTelemetryExtension` | Opt-out for `Microsoft.Extensions.Telemetry.Abstractions`. | | `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. | @@ -388,15 +429,24 @@ The SDK now exports its properties via `CompilerVisibleProperty`, so analyzers a | `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. | -| `PurviewAgentFolderSourcePath` | Folder that provides the bundled `.agents` content mirrored into the repository. | -| `PurviewAgentFolderCopyRetries` | Number of copy attempts per file before an agent folder sync failure is reported (default `3`). | -| `PurviewAgentFolderCopyRetryDelayMilliseconds` | Base delay between agent folder copy attempts (default `500`). | -| `PurviewAgentFolderCopyFailureAsError` | When `true` (default), an agent folder copy that still fails after every retry is an error. | -| `PurviewAgentSyncManifestPath` | Change-detection manifest used to skip unchanged agent content (default `/.purview/agent-sync.cache`). | | `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. | +| `EnablePurviewSkillRecommendations` | When `true` (default), an ordinary build reports missing or version-drifted agent skills. | +| `PurviewSkillRecommendationsInCI` | When `true`, skill recommendations are reported on CI builds as well (default `false`). | +| `PurviewSkillRecommendationStampPath` | Stamp used to report each distinct skill state once (default `/.purview/skill-recommendations.cache`). | +| `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` | Agents that `InstallPurviewSkills` installs to (default `*`). | +| `PurviewSkillsRunner` | Package runner used to launch the skills CLI (default `npx`; also `bunx`, `bun`, `pnpm`, `yarn`, `deno`, `skills`). | +| `PurviewSkillsRunnerCandidates` | Runners probed on `PATH` after `PurviewSkillsRunner` (default `npx;bunx;bun;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` | Relative path to generated AssemblyInfo source file. | +| `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). | #### Example: switch a repo to Xunit + NSubstitute and disable Bogus @@ -481,12 +531,47 @@ Define `PURVIEW_SDK_EXCLUDE_EMBEDDED` only when your build already provides `Mic --- +## Assembly identifier class + +Architecture tests need a handle on each assembly under test. Matching one by name (`Assembly.Load("Acme.Billing")`) is a string that no compiler checks, so a rename silently empties the test instead of failing it. + +`GenerateAssemblyIdentifierClassTarget` writes an empty marker class into the intermediate output for every production C# project, so the reference becomes a `typeof`: + +```csharp +// + +/// +/// Identifies the Acme.Billing assembly to architecture tests. +/// +public static partial class BillingAssembly +{ +} +``` + +```csharp +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. + +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. + +Test and shared-testing projects do not get one — the test assembly is the one doing the asserting, so it has nothing to identify. Set `DisableGenerateAssemblyIdentifierClass=true` to opt a production project out. + +--- + ## Namespace stripping 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: -`SourceGeneration`, `SourceGenerators`, `SourceGenerator`, `ServiceDefaults`, `Infrastructure`, `Abstractions`, `ClientShared`, `Persistence`, `CodeFixers`, `CodeFixes`, `DataAccess`, `Extensions`, `Generators`, `Analyzers`, `Contracts`, `Framework`, `Utilities`, `AppHost`, `Helpers`, `Library`, `Common`, `Shared`, `Infra`, `Utils`, `Core`, `Data`, `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 and shared-testing project names (`Shared*`, `SharedTesting*`). + +Three groups are deliberately **not** stripped: + +- **Roslyn component names** — `SourceGenerator`, `SourceGenerators`, `SourceGeneration`, `Generators`, `Analyzers`, `CodeFixers`, `CodeFixes`. A component's suffix is part of its identity, and stripping them produced `IDE0130` across every Roslyn-component repository consuming this SDK. +- **`Persistence`, `AppHost` and `Data`** — they read as layer noise but are routinely the whole point of the project. +- Anything a project opts back in with ``. ### Keeping suffixes (opt a stripped suffix back in) diff --git a/architecture-policy.schema.json b/architecture-policy.schema.json new file mode 100644 index 0000000..2555b54 --- /dev/null +++ b/architecture-policy.schema.json @@ -0,0 +1,168 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/purview-dev/build-sdk/main/architecture-policy.schema.json", + "title": "Purview architecture policy", + "description": "The authoritative project responsibility, naming and dependency policy. The architecture tests read this document; PolicyDocumentationTests asserts the wiki agrees with it. See docs/wiki/Project-Responsibilities.md.", + "type": "object", + "required": ["version", "responsibilities"], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "Pointer to this schema, so an editor validates the file as it is written." + }, + "version": { + "type": "integer", + "minimum": 1, + "description": "Schema version of this document. Increment when a change would break a reader that understood the previous shape." + }, + "description": { + "type": "string" + }, + "conventions": { + "type": "object", + "additionalProperties": false, + "properties": { + "qualifiedNameSeparator": { + "type": "string", + "minLength": 1, + "maxLength": 1, + "description": "Separates the responsibility from its implementation qualifier, as in Persistence.SqlServer." + }, + "dependencyArrowMeaning": { + "type": "string", + "description": "States what an arrow means, so compile-time references are not confused with runtime calls." + } + } + }, + "sourceRoots": { + "type": "array", + "description": "Repo-relative folders holding the projects. Defaults to [\"src\"]. An empty array scans the repository root, which suits a flat layout. Getting this wrong finds no projects, which the graphDiscovery rule reports rather than passing every other rule vacuously.", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true, + "default": ["src"] + }, + "responsibilities": { + "type": "array", + "minItems": 1, + "description": "The responsibility catalogue. A project name is [.]; only the first segment is classified.", + "items": { "$ref": "#/$defs/responsibility" } + }, + "discouragedProjectNames": { + "type": "object", + "required": ["names", "reason"], + "additionalProperties": false, + "description": "Names that describe a category rather than a responsibility, so nothing can be excluded from them.", + "properties": { + "names": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/projectNameSegment" } + }, + "reason": { "type": "string", "minLength": 20 } + } + }, + "rules": { + "type": "object", + "additionalProperties": false, + "description": "Repository-wide rules that are not expressed per responsibility.", + "properties": { + "noCycles": { "type": "boolean" }, + "noProductionToTestReferences": { "type": "boolean" }, + "implementationsMustNotReferenceComposition": { "type": "boolean" }, + "siblingImplementationsIndependent": { "type": "boolean" } + } + }, + "waivers": { + "type": "array", + "description": "Deliberate, reviewed departures. A waiver excuses the narrowest thing possible - usually the name - and mapsTo keeps the project classified so every dependency rule still applies.", + "items": { "$ref": "#/$defs/waiver" } + } + }, + "$defs": { + "projectNameSegment": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9]*$", + "description": "A single PascalCase segment, with no dots: the responsibility, not a qualified project name." + }, + "responsibility": { + "type": "object", + "required": ["name", "category", "purpose", "mayDependOn", "mustNotDependOn"], + "additionalProperties": false, + "properties": { + "name": { "$ref": "#/$defs/projectNameSegment" }, + "aliases": { + "type": "array", + "description": "Recognised alternative names. An established project using one of these is not renamed.", + "items": { "$ref": "#/$defs/projectNameSegment" } + }, + "category": { + "type": "string", + "enum": ["domain", "application", "infrastructure", "composition", "host", "shared"] + }, + "purpose": { "type": "string", "minLength": 20 }, + "mayDependOn": { + "type": "array", + "description": "Responsibilities this one may reference at compile time. An empty array means it depends on nothing.", + "items": { "$ref": "#/$defs/projectNameSegment" }, + "uniqueItems": true + }, + "mustNotDependOn": { + "type": "array", + "description": "Responsibilities this one must never reference. Must not overlap mayDependOn.", + "items": { "$ref": "#/$defs/projectNameSegment" }, + "uniqueItems": true + }, + "forbiddenPackagePrefixes": { + "type": "array", + "description": "NuGet identifier prefixes that must not appear in this responsibility, such as Microsoft.EntityFrameworkCore in Core.", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "siblingIndependence": { + "type": "boolean", + "description": "When true, qualified implementations of this responsibility must not reference one another.", + "default": false + }, + "separateProjectWhen": { "type": "string", "minLength": 10 }, + "mayRemainCombinedWhen": { "type": "string", "minLength": 10 } + } + }, + "waiver": { + "type": "object", + "required": ["project", "rules", "reason"], + "additionalProperties": false, + "properties": { + "project": { + "type": "string", + "minLength": 1, + "description": "The project name the waiver applies to, exactly as it appears on disk." + }, + "mapsTo": { + "$ref": "#/$defs/projectNameSegment", + "description": "The responsibility this project actually has. Keeps every dependency rule in force while excusing the name." + }, + "rules": { + "type": "array", + "minItems": 1, + "description": "The rules this waiver excuses. A waiver that excuses nothing does nothing.", + "items": { + "type": "string", + "enum": ["naming", "discouragedName"] + }, + "uniqueItems": true + }, + "reason": { + "type": "string", + "minLength": 40, + "description": "Why the departure is acceptable. A blanket suppression with no reason makes the policy unfalsifiable." + }, + "reviewedOn": { + "type": "string", + "format": "date", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + } + } + } +} diff --git a/docs/wiki/Agent-Folder.md b/docs/wiki/Agent-Folder.md deleted file mode 100644 index 0a0f124..0000000 --- a/docs/wiki/Agent-Folder.md +++ /dev/null @@ -1,89 +0,0 @@ -# Agent Folder - -`Purview.BuildSdk` ships bundled agent content (skills, prompts, agents) under `.agents/**` -in the NuGet package. During build, the SDK copies it into the consuming repository's `.agents/` -folder by default so compatible coding agents can discover repository-aware guidance automatically. - -## How the copy works - -- `EnableAgentFolderInPackage=true` (default) syncs the bundled `.agents/**` folder from the SDK - NuGet package into `$(AgentPackDestinationFolder)/` (default `.agents`) in the consuming repository - **before build**. -- The source folder defaults to the package-level `.agents` folder beside `Sdk/` and can be pointed - elsewhere with `PurviewAgentFolderSourcePath`. -- The destination root is resolved from `RepoRoot` (auto-discovered repo root), an `AGENTS.md` walk-up, - or SourceLink source roots. -- The sync is also performed for bundled `.agents` folders shipped by **any** restored NuGet package - that used `PurviewAutoSdkPack` (read from `project.assets.json`), not just this SDK. -- Unchanged content is skipped using a repository manifest (`.purview/agent-sync.cache` by default), so - repeat builds do not touch any file. The manifest records a per-file size/timestamp fingerprint plus a - content hash, which is what makes an in-place package republish (same version, new content) re-sync. - Deleting a mirrored file also re-triggers its copy. -- Every write is staged into a temporary file in the destination folder and then renamed into place, so a - reader never observes a partially written file and concurrent writers cannot interleave. -- Several projects share the same destination, so simultaneous writes are expected. Those retries are - logged at low importance only (MSB3026 copy retry notices are demoted to messages) and a copy that - still fails after every retry is reported as an **error** by default. - -## Failure and retry behaviour - -| Property | Default | Description | -| -- | -- | -- | -| `PurviewAgentFolderSourcePath` | *(package-level `.agents`)* | Folder that provides the bundled `.agents` content mirrored into the repository. | -| `PurviewAgentFolderCopyRetries` | `3` | Copy attempts per file before the failure is reported. | -| `PurviewAgentFolderCopyRetryDelayMilliseconds` | `500` | Base delay between attempts (a small increment is added per attempt). | -| `PurviewAgentFolderCopyFailureAsError` | `true` | When `false`, a copy that still fails after every retry is reported as a warning and the build continues. | -| `PurviewAgentSyncManifestPath` | `/.purview/agent-sync.cache` | Overrides the change-detection manifest location. | -| `PurviewSuppressCopyRetryWarnings` | `true` | Demotes MSB3026 copy retry notices to messages. Set to `false` to see every retry attempt. | - -## Opting out - -To disable bundled agent folder copying in a consuming repo, set the opt-out property before -importing the SDK: - -```xml - - false - -``` - -## Packaging the folder - -For packable projects, the SDK packs the `Sdk/.agents/**` folder into the package at `.agents/**` and -injects 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# This file is mirrored from a NuGet package, so ignore it too\n/.gitignore`. -This ensures the copied folder structure remains discoverable in consuming repositories while the -content itself is ignored by Git. The last entry ignores the generated file itself rather than -re-including it — a mirrored file is never committed, so re-including it would leave it permanently -untracked, and because the nearest `.gitignore` wins it would also override the generated -`.agents/.gitignore` described below. - -## Keeping the working tree clean - -Mirroring rewrites a file whenever the package that owns it changes, which would show up as a local -modification in every consuming repository. To prevent that, the SDK writes a generated -`.agents/.gitignore` listing every mirrored file by path. It is per file, not a blanket rule, because -`.agents/agents/` and `.agents/prompts/` hold mirrored files beside a repository's own authored ones — -ignoring those wholesale would hide the author's work. Files you author in the folder are not listed and -stay tracked as normal. - -Git only applies ignore rules to untracked paths, so a mirrored file that is already **tracked** — most -often committed before the SDK began ignoring mirrored files per file — is never ignored, and every -upgrade that changes it dirties the working tree. The SDK cannot untrack it for you, so it reports -`PurviewTrackedMirroredFile` the next time it mirrors that file, naming the paths and the command: - -``` -git rm --cached -- '.agents/agents/sdk-consumer-setup.md' '.agents/skills/demo/.gitignore' -``` - -The files stay on disk — every build rewrites them anyway. The check only runs on a build that actually -mirrored something, so an up-to-date build does not pay for it. - -| Property | Default | Description | -| -- | -- | -- | -| `PurviewAutoSdkPack` | `true` | When `true`, automatically packs the `Sdk/` folder contents into the NuGet package with the correct root-level paths. Disable this for MSBuild SDK projects. | -| `EnableAgentFolderInPackage` | `true` | Copies the bundled `.agents/**` folder from the SDK NuGet package into the consuming repository's `.agents/` folder (or `$(AgentPackDestinationFolder)/`) before build. | -| `AgentPackDestinationFolder` | `.agents` | Repo-relative destination folder that receives the copied agent folder contents when `EnableAgentFolderInPackage` is `true`. | -| `DisableMirroredFileTrackingCheck` | `false` | Set to `true` to stop the SDK reporting `PurviewTrackedMirroredFile` for mirrored files that Git tracks. | - -See [Packaging](Packaging.md) for the full `Sdk/` folder packaging rules. \ No newline at end of file diff --git a/docs/wiki/Agent-Skills.md b/docs/wiki/Agent-Skills.md new file mode 100644 index 0000000..4817da9 --- /dev/null +++ b/docs/wiki/Agent-Skills.md @@ -0,0 +1,487 @@ +# 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/) — `npx skills add`, `npx skills update`, `npx 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 SDK recommends commands. It never runs them. 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: npx skills add owner/repo#commit"] + Update["Suggest: pinned re-add
(not a blind npx skills update)"] + List["Suggest: npx skills list"] + Restore["Suggest: npx skills experimental_install"] + Use["Optional: npx 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 `npx 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 +npx skills add purview-dev/build-sdk # every skill +npx skills add purview-dev/build-sdk --skill purview-buildsdk-testing +npx 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 + +`npx 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 are the one part of this feature that runs during an ordinary build. They read the +restore graph and the CLI's own `skills-lock.json`, both local; they recommend commands and never +run them; and they never fail a build. + +```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 | `npx skills add "#" --skill …` | +| Installed from a different ref | `PRSKL0011` | Warning | pinned re-add at the referenced commit | +| Compatibility unverifiable | `PRSKL0012` | Message | `npx skills list` | +| From a repository no longer referenced | `PRSKL0013` | Message | `npx skills remove ""` | +| Lockfile present, nothing restored | `PRSKL0014` | Message | `npx 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. + +### Staying quiet + +| Situation | Behaviour | +| -- | -- | +| Everything installed and compatible | No output at all | +| Design-time build (`DesignTimeBuild=true`) | Skipped | +| CI build (`ContinuousIntegrationBuild`, `TF_BUILD`, `GITHUB_ACTIONS`, `CI`) | Skipped unless opted in | +| Non-default target framework in a multi-targeting build | Skipped | +| Same state as last reported | Skipped | + +The last one is deduplication. A solution has many projects sharing one repository, and a rebuild +changes nothing, so a fingerprint of the reported state is kept in +`/.purview/skill-recommendations.cache`. The same advice is printed once, and again only +when the state it describes actually changes. The explicit `-t:RecommendPurviewSkills` target +ignores the stamp and always answers. + +### Configuration + +| Property | Default | Description | +| -- | -- | -- | +| `EnablePurviewSkillRecommendations` | `true` | Reports skill state after an ordinary build. | +| `PurviewSkillRecommendationsInCI` | `false` | Set `true` to report on CI builds too. | +| `PurviewSkillRecommendationStampPath` | `/.purview/skill-recommendations.cache` | Overrides the deduplication stamp. | +| `PurviewSkillsLockFile` | `/skills-lock.json` | Overrides where the CLI's lockfile is read from. | +| `PurviewSkillsAgents` | `*` | Agents named in the recommended install command. | +| `PurviewSkillsRunner` | `npx` | Package runner used to launch the CLI. | +| `PurviewSkillsRunnerCandidates` | `npx;bunx;bun;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. | + +## 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 **`npx 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 +npx skills add "purview-dev/purview-example#0123456789abcdef0123456789abcdef01234567" \ + --skill "purview-alpha" --agent "*" -y +``` + +`npx skills update` remains the right command when the intent is to track the latest published +skills — for example right after upgrading the package reference, once the new version is restored +and the SDK reports the state as compatible again. + +> 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 (`npx 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 +npx 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 (`npx 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**. Neither target has `BeforeTargets`/`AfterTargets`: + +```bash +dotnet msbuild -t:ListPurviewSkills # diagnostics only, installs nothing +dotnet msbuild -t:InstallPurviewSkills # delegates to the skills CLI +``` + +`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`. + +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 **`npx`**, because npx ships with Node itself and is +therefore the one runner any machine with Node at all already has. + +| `PurviewSkillsRunner` | Executed | Recommended in diagnostics | +| -- | -- | -- | +| `npx` *(default)* | `npx -y skills@1` | `npx skills …` | +| `bunx` | `bunx skills@1` | `bunx skills …` | +| `bun` | `bun x skills@1` | `bun x 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 `npx 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 for skills installed +from elsewhere. 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 | +| `.claude/skills/`, `agent/skills/`, … | per-agent links into it | no | + +> **The CLI's universal location is `.agents/skills/`.** Ignore that specifically rather than the +> whole of `.agents/`, so any authored `.agents/agents/` and `.agents/prompts/` content stays +> 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)` — `npx;bunx;bun;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 `'npx' 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 +npx 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 + +`npx 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 `npx 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 | +| -- | -- | -- | +| `npx skills add ` | Install skills | The installation workflow; accepts `owner/repo#` | +| `npx skills list` | List installed skills | Diagnostics; `--json` is machine-readable but omits `ref` | +| `npx skills find [query]` | Search for skills | Discovery fallback where no package mapping exists | +| `npx skills update` | Update to latest | Correct only when latest matches the referenced package | +| `npx skills remove` | Remove skills | Developer-managed | +| `npx skills use @` | Use without installing | One-off guidance | +| `npx skills experimental_install` | Restore from `skills-lock.json` | Optional bootstrap; experimental | +| `npx skills init [name]` | Scaffold a skill | Authoring | +| `npx 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 | +| `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 | + +## See also + +- [Configuration Reference](Configuration-Reference.md) +- [Packaging](Packaging.md) diff --git a/docs/wiki/Assembly-Name-Generation.md b/docs/wiki/Assembly-Name-Generation.md index 7c5efe0..2d02abe 100644 --- a/docs/wiki/Assembly-Name-Generation.md +++ b/docs/wiki/Assembly-Name-Generation.md @@ -42,11 +42,16 @@ Certain suffixes are automatically stripped from `RootNamespace` to avoid awkwar `Acme.MyProject.Core.Something`. The built-in stripping set is broader than the common examples and includes: -`ServiceDefaults`, `Infrastructure`, `Abstractions`, `ClientShared`, `Persistence`, `DataAccess`, -`Extensions`, `Contracts`, `Framework`, `Utilities`, `AppHost`, `Helpers`, `Library`, `Common`, -`Shared`, `Infra`, `Utils`, `Core`, `Data`, `Host`, `Util`, `Lib`, `EF`, plus the known shared and +`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*`). +`Persistence`, `AppHost` and `Data` are deliberately absent. They read as layer noise but are +routinely the whole point of the project — an `Acme.Billing.Data` assembly collapsed onto +`Acme.Billing` loses the distinction its sources declare. Add one with +`` if a repository wants it collapsed. + **Roslyn component names are deliberately absent.** `SourceGenerator`, `SourceGenerators`, `SourceGeneration`, `Generators`, `Analyzers`, `CodeFixers` and `CodeFixes` are part of a component's identity rather than noise: a generator, its analyzers and its code fixes are separate assemblies that @@ -81,5 +86,41 @@ kept as-is. That is why an analyser or source-generator repository that names pr no `Remove` at all. Those roles survive in the namespace by default, and a `Remove` for them is a harmless no-op rather than the thing keeping them. +## Assembly identifier class + +Architecture tests need a handle on each assembly under test. Matching one by name +(`Assembly.Load("Acme.Billing")`) is a string that no compiler checks, so a rename silently empties +the test instead of failing it. + +`GenerateAssemblyIdentifierClassTarget` writes an empty marker class into the intermediate output for +every production C# project, so the reference becomes a `typeof`: + +```csharp +// + +/// +/// Identifies the Acme.Billing assembly to architecture tests. +/// +public static partial class BillingAssembly +{ +} +``` + +```csharp +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, and +`AutoGeneratedAssemblyIdentifierFile` to change the generated file 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. + +Test and shared-testing projects do not get one — the test assembly is the one doing the asserting, so +it has nothing to identify. Set `DisableGenerateAssemblyIdentifierClass=true` to opt a production +project out. + See [Project Naming Conventions](Project-Naming-Conventions.md) and [InternalsVisibleTo](InternalsVisibleTo.md) for related naming behaviour. diff --git a/docs/wiki/Configuration-Reference.md b/docs/wiki/Configuration-Reference.md index a60f4bb..a17018b 100644 --- a/docs/wiki/Configuration-Reference.md +++ b/docs/wiki/Configuration-Reference.md @@ -33,6 +33,8 @@ See [Version Detection](Version-Detection.md) for the full resolution rules. | `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). | +| `AssemblyIdentifierClassName` | `Assembly` | Overrides the name of the generated assembly identifier marker class. | | `AutoIncludeUsings` | `true` | Controls SDK-added global usings for `NamespacePrefix` and `RootNamespace`. | | `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). | @@ -162,32 +164,15 @@ observe a partially written `.editorconfig` or `global.json`. Because every proj the same bootstrapping targets, a lost race between parallel projects is a no-op: an existing file is treated as success rather than a copy failure. -## Agent folder +## Sdk folder packaging | Property | Default | Description | | -- | -- | -- | | `PurviewAutoSdkPack` | `true` | When `true`, automatically packs the `Sdk/` folder contents into the NuGet package with the correct root-level paths. Disable this for MSBuild SDK projects. | -| `EnableAgentFolderInPackage` | `true` | Mirrors the bundled `.agents/**` folder from the SDK NuGet package into the consuming repository's `.agents/` folder (or `$(AgentPackDestinationFolder)/`) before build. | -| `AgentPackDestinationFolder` | `.agents` | Repo-relative destination folder that receives the mirrored agent folder contents when `EnableAgentFolderInPackage` is `true`. | -| `PurviewAgentFolderSourcePath` | *(package-level `.agents`)* | Overrides the folder that provides the bundled `.agents` content. | -| `PurviewAgentFolderCopyRetries` | `3` | Copy attempts per file before the failure is reported. | -| `PurviewAgentFolderCopyRetryDelayMilliseconds` | `500` | Base delay between copy attempts. | -| `PurviewAgentFolderCopyFailureAsError` | `true` | When `false`, a copy that still fails after every retry is reported as a warning and the build continues. | -| `PurviewAgentSyncManifestPath` | `/.purview/agent-sync.cache` | Overrides the change-detection manifest used to skip unchanged agent content. | - -The mirror is change-aware: content that already matches the manifest is skipped entirely, which keeps -repeat builds free of file writes and file locks. Retry notices are demoted to low-importance messages -(`MSB3026`); a copy that still fails after every retry is reported as an error that names the source, -destination and OS error. - -To disable bundled agent folder copying in a consuming repo, set the opt-out property before -importing the SDK: -```xml - - false - -``` +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). ## Telemetry @@ -229,10 +214,10 @@ read them through `build_property.`: | `DisableSourceLink` | Disables automatic SourceLink integration. | | `ExcludePurviewTelemetry` | Opt-out for `Purview.Telemetry.SourceGenerator`. | | `ExcludeMSTelemetryExtension` | Opt-out for `Microsoft.Extensions.Telemetry.Abstractions`. | -| `EnableAgentFolderInPackage` | When `true`, copies the bundled `.agents` folder into the consuming repository. | -| `AgentPackDestinationFolder` | Repo-relative destination folder that receives copied `.agents` content. | | `PurviewAutoSdkPack` | When `true`, automatically packs the `Sdk/` folder contents into the NuGet package. | | `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. | @@ -258,7 +243,8 @@ read them through `build_property.`: | `PurviewBuildSdkVersionForGlobalJson` | Version used for `msbuild-sdks.Purview.BuildSdk` when bootstrapping `global.json`. | | `DisableAutoCopySdkFiles` | When `true`, disables SDK auto-copy/bootstrap for repo files (`.editorconfig`, `global.json`). | | `CurrentYear` | Current year used in generated assembly metadata. | -| `AutoGeneratedAssemblyInfoFile` | Relative path to generated AssemblyInfo source file. | +| `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 diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index cd85414..87b27af 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -24,7 +24,7 @@ repo root inherits everything automatically. - [SDK-Shipped Analyzers](Analyzers.md) - [Code Fixes](Code-Fixes.md) - [Repository Bootstrap](Repository-Bootstrap.md) -- [Agent Folder](Agent-Folder.md) +- [Agent Skills](Agent-Skills.md) - [Release Flow](Release-Flow.md) ## Feature highlights diff --git a/docs/wiki/Testing-Wiring.md b/docs/wiki/Testing-Wiring.md index e25bc6b..ae3771a 100644 --- a/docs/wiki/Testing-Wiring.md +++ b/docs/wiki/Testing-Wiring.md @@ -54,7 +54,10 @@ All test and shared-testing projects get: - `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`. +- `IsPackable=false`, `IsPublishable=false`, `MaxCpuCount=0`, `DisableGenerateAssemblyInfoClass=true`, + `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. - An `[assembly: ExcludeFromCodeCoverage]` attribute. diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 6bf86e7..e7e7950 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -12,5 +12,5 @@ - [SDK-Shipped Analyzers](Analyzers.md) - [Code Fixes](Code-Fixes.md) - [Repository Bootstrap](Repository-Bootstrap.md) -- [Agent Folder](Agent-Folder.md) +- [Agent Skills](Agent-Skills.md) - [Release Flow](Release-Flow.md) diff --git a/package.json b/package.json index 828ca55..511de7e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "build-sdk", - "version": "1.0.5", + "version": "1.0.6", "homepage": "https://purview.dev/projects/build-sdk/", "bugs": { "url": "https://github.com/purview-dev/build-sdk/issues" diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..ff044d1 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "skills": {} +} diff --git a/skills/purview-buildsdk-adoption/SKILL.md b/skills/purview-buildsdk-adoption/SKILL.md new file mode 100644 index 0000000..b1f222c --- /dev/null +++ b/skills/purview-buildsdk-adoption/SKILL.md @@ -0,0 +1,114 @@ +--- +name: purview-buildsdk-adoption +description: Adopt Purview.BuildSdk in a repository, or troubleshoot an adoption that is not behaving. Use when wiring the SDK into Directory.Build.props for the first time, when a property seems to have no effect, when the resolved version or namespace is wrong, or when a consuming repository builds but the SDK's automatic behaviour is not appearing. Covers the import chain, pre-import property ordering, and version detection from package.json. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Adopting Purview.BuildSdk + +The goal is a working adoption that does not break existing build behaviour. Prefer minimal, +targeted property changes over rewriting a repository's `Directory.Build.props`. + +For the full property list use `purview-buildsdk-configuration`; for auditing an existing +repository's shape, `purview-buildsdk-repository-review`. + +## 1. The import chain + +```xml + + + + + Acme.Sales + + + + +``` + +```xml + + + + +``` + +Both halves are required. A repository with only `Sdk.props` imported gets the evaluation-time +defaults — naming, identity, framework selection — but none of the targets, so validation, +packaging behaviour and the generated files never run. That combination usually presents as "the +properties look right but nothing happens". + +The SDK version comes from `global.json`, which the SDK itself bootstraps. + +## 2. Pre-import ordering is the first thing to check + +**A property that must affect `Sdk.props` evaluation has to be set before the import.** Setting it +in a `.csproj`, or below the import, is too late and silently does nothing — no error, no warning. +This is the single most common adoption failure. + +Set before the import: + +- `NamespacePrefix` +- `UsePackageJsonVersion`, `RootPackageJson` +- `EnableAssemblyNameGeneration` +- `AutoIncludeUsings` +- `PurviewSharedTestingOutputType`, `PurviewTestContextNoWarn` +- any repo-wide testing-framework selection you want every project to inherit + +Everything else is read in `Sdk.targets` and can be set in a project file. + +When a property appears to have no effect, confirm what MSBuild actually resolved before changing +anything else: + +```bash +dotnet msbuild -getProperty:NamespacePrefix -getProperty:RootNamespace +``` + +## 3. Version detection + +Versions come from `package.json`. Resolution order: + +1. An explicit `RootPackageJson`. +2. CI workspace variables. +3. The `.git` root. +4. A nearby `package.json`. + +On success both `Version` and `PackageVersion` are set from the `version` field. + +If the version looks wrong, do not guess at the discovery — make it fail loudly instead: + +```xml +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. + +## 4. Naming and placement + +Most of the SDK's value is inferred from project names and locations, so an adoption that keeps a +bespoke structure gets correspondingly little. Confirm the repository follows the conventions rather +than introducing new structure to work around them — see `purview-buildsdk-project-layout`, and +`purview-buildsdk-repository-review` for auditing an existing layout. + +The highest-value single check: every test project is named `.Tests`. **The dot is +required.** A project named plainly `UnitTests` is not detected as a test project at all, and +silently gets no runner, no coverage, no `InternalsVisibleTo` and no category. + +## 5. Agent skills are a separate, explicit step + +The SDK does not install agent skills, and nothing it does during a build writes to an agent +directory. It reads which skills each referenced package ships and recommends the command that +installs them; the skills CLI does the installing. See `purview-buildsdk-skill-installation`. + +## Constraints + +- Prefer minimal, targeted property changes over broad `Directory.Build.props` rewrites. +- Do not disable `PurviewAutoSdkPack` unless the consumer explicitly asks to opt out. +- Do not restate an SDK default in a project file. Hand-configuration that merely repeats a default + is pure maintenance cost, and it hides the fact that the default later changed. +- Re-run the build — or the repository's canonical build command — after each change, and confirm + the fix before moving to the next one. diff --git a/skills/purview-buildsdk-architecture-tests/SKILL.md b/skills/purview-buildsdk-architecture-tests/SKILL.md new file mode 100644 index 0000000..fad0dd5 --- /dev/null +++ b/skills/purview-buildsdk-architecture-tests/SKILL.md @@ -0,0 +1,143 @@ +--- +name: purview-buildsdk-architecture-tests +description: Adopt or debug Purview.Architecture.Testing, the package that enforces a repository's project dependency and naming policy as TUnit tests. Use when adding architecture tests to a repository, writing or changing .config/architecture-policy.json, recording a waiver, interpreting a dependency-matrix, cycle, sibling-independence or naming failure, or when the architecture tests pass but enforce nothing. +license: MIT +metadata: + package: Purview.Architecture.Testing + repository: purview-dev/build-sdk +--- + +# Architecture tests with Purview.Architecture.Testing + +The package defines the rules once so a policy is not copied into every repository's test project. + +## Adoption is two things + +**1. A policy file** at `.config/architecture-policy.json`: + +```json +{ + "$schema": "https://raw.githubusercontent.com/purview-dev/build-sdk/main/architecture-policy.schema.json", + "version": 1, + "sourceRoots": ["src"], + "responsibilities": [ + { + "name": "Core", + "aliases": ["Domain"], + "category": "domain", + "purpose": "The domain model, its invariants and its rules.", + "mayDependOn": [], + "mustNotDependOn": ["Application", "Persistence"] + } + ] +} +``` + +**2. One derived test class:** + +```csharp +[InheritsTests] +public sealed class ArchitectureTests : PurviewArchitectureTests; +``` + +## `[InheritsTests]` is load-bearing + +TUnit does **not** walk base types during discovery. Without the attribute the class compiles, the +run is green, and nothing is enforced — the worst failure this package can have. + +TUnit's analyzer reports `TUnit0030` when it is missing. Treat it as an error, not a suggestion. + +If the architecture tests "pass" but you cannot name which rule ran, check this first. + +## The rules + +| Rule id | Test | What it catches | +| -- | -- | -- | +| `graphDiscovery` | `Repository_GivenItsSourceRoots_YieldsProjectsToCheck` | An empty graph — which satisfies every other rule | +| `naming` | `ProductionProjects_GivenTheirNames_MapToAResponsibility` | A project name matching no responsibility or alias | +| `discouragedName` | `ProductionProjects_GivenTheirNames_AvoidCatchAllNames` | A project named after a category, so nothing can be kept out of it | +| `dependencyMatrix` | `ProductionProjects_GivenTheirReferences_RespectTheDependencyMatrix` | A reference the matrix forbids or does not permit | +| `siblingIndependence` | `SiblingImplementations_GivenTheirReferences_DoNotDependOnEachOther` | A qualified implementation referencing a qualified sibling | +| `compositionBoundary` | `Implementations_GivenTheirReferences_DoNotDependOnComposition` | A non-host project referencing Composition | +| `forbiddenPackage` | `ProductionProjects_GivenTheirPackages_AvoidForbiddenFrameworkDependencies` | A package matching a forbidden prefix for that responsibility | +| `cycle` | `ProjectGraph_GivenItsReferences_HasNoCycles` | A dependency cycle, reported readably | +| `testReference` | `ProductionProjects_GivenTheirReferences_DoNotReferenceATestProject` | Production code referencing a test project | +| `waiverIntegrity` | `PolicyWaivers_GivenTheirDefinitions_AreJustifiedAndStillApply` | A waiver that is stale, unjustified or malformed | + +A separate test validates the policy against the schema first, because a policy that does not match +its schema may be silently missing a rule. + +`graphDiscovery` runs before the rules it protects. If `sourceRoots` is wrong, discovery finds +nothing and every other rule passes vacuously — that is what this rule exists to stop. + +## Prefer a waiver to a skip + +```csharp +protected override bool SkipRule(string rule) => rule == ArchitectureRules.DiscouragedName; +``` + +`SkipRule` removes a rule for the whole repository, with no record. A waiver is better: it is per +project, carries a reason and a review date, keeps every other rule in force for that project, and +is itself checked by `waiverIntegrity`. + +```json +"waivers": [ + { + "project": "Legacy.Utilities", + "mapsTo": "Shared", + "rules": ["naming"], + "reason": "Predates the catalogue; renaming breaks a published package id.", + "reviewedOn": "2026-01-14" + } +] +``` + +`mapsTo` is the useful part: it maps an established project onto the responsibility it actually has, +so the dependency rules still apply in full and only the *name* is excused. + +## Reading a failure + +Each rule is its own test, so a failure names the rule rather than collapsing the set into one +assertion. The `.Because(...)` text lists every violation the rule found. + +- `naming` — a project name is `[.]`, and only the **first segment** + is classified. `Persistence.SqlServer` and `Persistence` are both Persistence. Add an alias, or a + waiver with `mapsTo`. +- `dependencyMatrix` — fix the reference, or move the type. Widening `mayDependOn` is the last + resort, and it is a policy change that needs review. +- `graphDiscovery` — almost always a wrong `sourceRoots`. An empty array scans the repository root, + which suits a flat layout. + +## The three layers + +Useful when extending the package rather than consuming it: + +- `ArchitecturePolicy` / `ProjectGraph` — read the policy and the `.csproj` graph. No MSBuild and no + test-framework types. +- `ArchitecturePolicyEvaluator` — the rules, returning typed `ArchitectureViolation` lists. It takes + an **in-memory graph**, so a rule is tested as a three-project fixture rather than a repository on + disk. +- `PurviewArchitectureTests` — the TUnit wrapper, one `[Test]` per rule. + +Project files are read rather than compiled assemblies, because a declared `ProjectReference` is +what a reviewer adds and what the policy governs. + +## Overrides + +```csharp +[InheritsTests] +public sealed class ArchitectureTests : PurviewArchitectureTests +{ + protected override string? RepositoryRoot => "/path/to/repo"; +} +``` + +`RepositoryRoot` defaults to probing upward from the test binary for the policy file. The search +**stops at the repository boundary** (`.git`) rather than continuing into parent folders — walking +past it would happily find a sibling checkout's policy and validate this repository against someone +else's rules. + +## Related + +The equivalent for agent skills is `purview-buildsdk-skill-tests`, which follows the same shape: +read-only layers, a typed violation list, and a TUnit base class adopted with `[InheritsTests]`. diff --git a/skills/purview-buildsdk-configuration/SKILL.md b/skills/purview-buildsdk-configuration/SKILL.md new file mode 100644 index 0000000..f0ee715 --- /dev/null +++ b/skills/purview-buildsdk-configuration/SKILL.md @@ -0,0 +1,110 @@ +--- +name: purview-buildsdk-configuration +description: Configure Purview.BuildSdk from Directory.Build.props or a .csproj. Use when setting NamespacePrefix, choosing a testing or mocking framework, turning telemetry or SourceLink off, controlling version detection from package.json, tuning the repository bootstrap, or working out whether a property must be set before the SDK import. Also use when the SDK "did something automatically" and you need the property that controls it. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Purview.BuildSdk configuration + +Use this skill when the question is "what property controls this, and where do I set it?" + +For *why* the SDK classified a project a particular way, use `purview-buildsdk-project-detection` +instead. For where projects and tests should live, use `purview-buildsdk-project-layout`. + +## First rule: know where a property must be set + +A property that changes `Sdk.props` evaluation must be set **before** the SDK import — in +`Directory.Build.props`, above the import. Setting it in a `.csproj` is too late and silently has no +effect. + +Pre-import properties include: + +- `NamespacePrefix` +- `UsePackageJsonVersion`, `RootPackageJson` +- `EnableAssemblyNameGeneration` +- `PurviewSharedTestingOutputType`, `PurviewTestContextNoWarn` +- `AutoIncludeUsings` +- repo-wide testing-framework selection you want every project to inherit + +Everything else is read in `Sdk.targets` and can be set in a project file. Prefer repo-wide defaults +in `Directory.Build.props` unless the setting is deliberately project-specific. + +## The properties you will reach for most + +| Property | Default | What it does | +| -- | -- | -- | +| `NamespacePrefix` | *(required)* | The root identity. Required unless `DisableNamespacePrefixCheck=true` | +| `TestingFramework` | `TUnit` | `TUnit`, `Xunit` or `None` | +| `SubstituteFramework` | `TUnitMocks` | `TUnitMocks`, `NSubstitute` or `None` | +| `TestDataFramework` | `Bogus` | `Bogus` or `None` | +| `TargetFramework` | `net10.0` | `netstandard2.0` when `IsRoslynComponent=true` | +| `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 | +| `EnableAssemblyNameGeneration` | `true` | `AssemblyName`/`PackageId` follow the resolved `RootNamespace` | + +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 — +several of these have near-miss names that do nothing. + +## Derived properties to inspect, not re-derive + +When explaining what the SDK did, read the value it computed rather than reasoning from the folder +name: + +`PurviewLogicalProjectName`, `PurviewNamespacePrefix`, `PurviewProjectShortName`, `PurviewTestType`, +`RootNamespace`, `AssemblyName`, `PackageVersion`, `TestingType`, `TargetProjectName`, `RepoRoot`, +`RootPackageJson`. + +Ask MSBuild for one directly: + +```bash +dotnet msbuild -getProperty:RootNamespace +dotnet msbuild -getProperty:TestingType -getProperty:TargetProjectName +``` + +## Compiler-visible properties + +The SDK exports its properties to analyzers and generators as `build_property.`. When +authoring an analyzer, read the exported property instead of re-deriving SDK behaviour — the export +is the SDK's answer, and it stays correct when the rules change. The exported set is listed in +[references/properties.md](references/properties.md). + +## Version detection + +1. If `RootPackageJson` is set, that file is used. +2. Otherwise the repo root is discovered from CI variables, `.git`, or a nearby `package.json`. +3. On success, both `Version` and `PackageVersion` come from the `version` field. +4. `UsePackageJsonVersion=Strict` turns a failed discovery into a build failure instead of a silent + fallback. + +## Repository bootstrap + +The SDK bootstraps two repository-wide files from the package: `.editorconfig` and `global.json`. + +- `PurviewRepoBootstrapMode` — `IfMissing` (default), `Always`, `WarnOnDrift`, `Never` +- `DisableAutoCopySdkFiles` — the master off switch +- `PurviewRepoBootstrapCopyRetries` / `...RetryDelayMilliseconds` / `...FailureAsError` + +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 +file and renamed into place, retries are quiet, and only a failure that survives every retry is +reported. + +**The SDK writes nothing into an agent directory.** It used to mirror a package's `.agents/**` into +the repository; that is gone, along with `EnableAgentFolderInPackage`, `AgentPackDestinationFolder` +and the `PurviewAgentFolder*` properties. Skills have exactly one installer, the skills CLI — see +`purview-buildsdk-skill-installation`. + +## Guidance when changing SDK 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. diff --git a/skills/purview-buildsdk-configuration/references/properties.md b/skills/purview-buildsdk-configuration/references/properties.md new file mode 100644 index 0000000..93a0d9b --- /dev/null +++ b/skills/purview-buildsdk-configuration/references/properties.md @@ -0,0 +1,146 @@ +# Purview.BuildSdk property reference + +Every user-facing property, grouped by what it changes. Defaults are what the SDK applies when the +consuming project supplies nothing. + +## Identity and naming + +| Property | Default | Behaviour | +| -- | -- | -- | +| `NamespacePrefix` | *(required)* | The root identity source. Required unless the check is disabled. Set before the SDK import | +| `DisableNamespacePrefixCheck` | `false` | Stops the build failing when `NamespacePrefix` is unset | +| `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` | +| `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 | + +## Target framework and language + +| Property | Default | Behaviour | +| -- | -- | -- | +| `TargetFramework` | `net10.0` | Applied when neither `TargetFramework` nor `TargetFrameworks` is set. A project that explicitly declares `IsRoslynComponent=true` defaults to `netstandard2.0` | +| `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` | +| `PackProjectReferencedSourceGenerators` | `true` | Packable projects include analyzer `ProjectReference` outputs under `analyzers/dotnet/cs/`. Use `Pack="false"` on one reference to opt that one out | + +## Style policy + +| Property | Default | Behaviour | +| -- | -- | -- | +| `DisablePurviewStylePolicyValidation` | `false` | Stops the build failing (`PRSGD0006`–`PRSGD0009`) when the repository `.editorconfig` overrides the modifier policy — `dotnet_style_require_accessibility_modifiers` other than `omit_if_default`, hiding `IDE0040`/`IDE1006`, disabling the Style category in bulk, weakening the `_camelCase` private-instance-field rule, or adding the accessibility rules to `NoWarn`. Entries the SDK injects itself are ignored | +| `PurviewTestContextNoWarn` | `CA1002;CA1012;CA1034;CA1047;CA1050;CA1051;CA1062;CA1064;CA1515;CA1707` | The production API-surface rules exempted in test and shared-testing projects. Test projects keep the strict style contract (`IDE0040`, field naming, formatting, `IDE1006`) but are context aware: public test classes, `Method_Scenario_Expectation` names, exposed fields and unvalidated helper parameters are allowed. Set before the SDK import | +| `DisablePurviewTestContextRuleSet` | `false` | Makes test and shared-testing projects enforce the production API-surface rules too | +| `PurviewPolicyExemptNoWarn` | *(derived)* | The SDK-injected `NoWarn` entries `ValidatePurviewStylePolicy` accepts | + +## Testing + +| Property | Default | Behaviour | +| -- | -- | -- | +| `TestingFramework` | `TUnit` | `TUnit`, `Xunit`, `None` | +| `SubstituteFramework` | `TUnitMocks` | `TUnitMocks`, `NSubstitute`, `None` | +| `TestDataFramework` | `Bogus` | `Bogus`, `None` | +| `CollectCoverage` | `true` for detected test projects | | +| `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 | + +## Packaging + +| Property | Default | Behaviour | +| -- | -- | -- | +| `IsPackable` | `false` | | +| `PurviewAutoSdkPack` | `true` | Packs `Sdk/**/*` into the package at the right root-level paths | +| `PurviewSkillsSourceFolder` | `/skills` | Where authored skills live | + +Packable-project defaults, applied only when the project supplied nothing: +`GenerateDocumentationFile=true`, `IncludeSymbols=true`, `SymbolPackageFormat=snupkg`, +`PublishRepositoryUrl=true`, `EmbedUntrackedSources=true`, `DebugType=portable`. Portable PDBs ship +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. + +Non-packable projects, including web apps, default `WarnOnPackingNonPackableProject=false` so a +solution-wide pack skips them silently. + +`ContinuousIntegrationBuild` is set only by real CI environment variables. Packability alone never +forces SourceLink's CI-mode dirty-repository checks. + +## Telemetry and SourceLink + +| Property | Default | Behaviour | +| -- | -- | -- | +| `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` | | +| `DisableSourceLink` | `false` | | + +## Version detection + +| Property | Default | Behaviour | +| -- | -- | -- | +| `UsePackageJsonVersion` | `true` | `true`, `false`, `Strict` | +| `RootPackageJson` | *(discovered)* | Explicit path to the `package.json` to read | +| `EnableVersionDetectionCache` | `true` | | +| `VersionDetectionCacheFile` | *(derived)* | | +| `VersionDetectionLogEnabled` | `false` | Logs the detected version | + +## Repository bootstrap + +| Property | Default | Behaviour | +| -- | -- | -- | +| `DisableAutoCopySdkFiles` | `false` | Master switch for SDK-managed repository file writes | +| `PurviewRepoBootstrapMode` | `IfMissing` | Also `Always`, `WarnOnDrift`, `Never` | +| `BootstrapEditorConfigToRepoRoot` | `true` | | +| `RepositoryEditorConfigFilePath` | *(derived)* | Overrides the destination `.editorconfig` | +| `BootstrapGlobalJsonToRepoRoot` | `true` | | +| `RepositoryGlobalJsonFilePath` | *(derived)* | Overrides the destination `global.json` | +| `PurviewBuildSdkVersionForGlobalJson` | detected SDK version, else `1.0.0` | | +| `PurviewRepoBootstrapCopyRetries` | `3` | | +| `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | | +| `PurviewRepoBootstrapCopyFailureAsError` | `true` | `false` downgrades a persistent write failure to a warning | +| `PurviewSuppressCopyRetryWarnings` | `true` | Demotes the built-in copy task's `MSB3026` retry notice to a message | +| `PurviewRepositoryRootMarkers` | `.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json` | Ordered markers identifying a repository root, most authoritative first | + +There is no agent-folder mirror. `EnableAgentFolderInPackage`, `AgentPackDestinationFolder`, +`PurviewAgentFolderSourcePath`, `PurviewAgentFolderCopy*`, `PurviewAgentSyncManifestPath` and +`DisableMirroredFileTrackingCheck` were removed: skills are installed by the skills CLI, and +nothing else was left for the mirror to deliver. + +## Skills + +Covered in full by `purview-buildsdk-skill-installation`: `PurviewSkillsRunner`, +`PurviewSkillsRunnerCandidates`, `PurviewSkillsCommand`, `PurviewSkillsPackage`, +`PurviewSkillsAgents`, `PurviewSkillsSource`, `PurviewSkillsInstallRoot`, `PurviewSkillsRepoRoot`, +`EnablePurviewSkillRecommendations`, `PurviewSkillRecommendationsInCI`. + +## Compiler-visible properties + +Exported to analyzers and generators as `build_property.`: + +- `UsePackageJsonVersion`, `RootPackageJson`, `RepoRoot`, `Version`, `PackageVersion` +- `NamespacePrefix`, `DisableNamespacePrefixCheck` +- `TestingFramework`, `SubstituteFramework`, `TestDataFramework` +- `ExcludePurviewTelemetry`, `ExcludeMSTelemetryExtension` +- `EnableAssemblyNameGeneration`, `DisableAutoInternalsVisibleTo`, `DisableGenerateAssemblyInfoClass`, + `DisableGenerateAssemblyIdentifierClass`, `AssemblyIdentifierClassName` +- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject` +- `TestingType`, `TargetProjectName` +- `IsContainerProject`, `IsSdkProject`, `SdkProjectName`, `IsWebProject`, `IsWebSdkProject`, + `IsWorkerSdkProject`, `IsAspireHostProject`, `IsCLIProject` +- `EditorConfigFilePath`, `RepositoryEditorConfigFilePath`, `BootstrapEditorConfigToRepoRoot` +- `RepositoryGlobalJsonFilePath`, `BootstrapGlobalJsonToRepoRoot`, `DisableAutoCopySdkFiles` +- `PurviewRepoBootstrapMode`, `PurviewRepoBootstrapCopyRetries`, + `PurviewRepoBootstrapCopyRetryDelayMilliseconds`, `PurviewRepoBootstrapCopyFailureAsError` +- `PurviewSuppressCopyRetryWarnings` +- `PurviewSkillsSourceFolder`, + `PurviewSkillsAgents`, `PurviewSkillsInstallRoot`, `PurviewRepositoryRootMarkers`, + `PurviewSkillsRunner` +- `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile`, + `AutoGeneratedAssemblyIdentifierFile` diff --git a/skills/purview-buildsdk-project-detection/SKILL.md b/skills/purview-buildsdk-project-detection/SKILL.md new file mode 100644 index 0000000..2d0d986 --- /dev/null +++ b/skills/purview-buildsdk-project-detection/SKILL.md @@ -0,0 +1,134 @@ +--- +name: purview-buildsdk-project-detection +description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, CLI, web, worker, Aspire host, container or Roslyn component, and why it added a package, a project reference, a namespace or an analyzer automatically. Use when SDK behaviour looks surprising, when a test project gets no runner or no InternalsVisibleTo, or when reasoning about RootNamespace, AssemblyName and TargetProjectName. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Why Purview.BuildSdk did that + +Use this skill when the question is **why** the SDK applied something automatically. + +For where a project should live, use `purview-buildsdk-project-layout`. For the property that turns +a behaviour off, use `purview-buildsdk-configuration`. + +## Diagnose from computed properties, never from the folder name + +Read what the SDK actually computed: + +```bash +dotnet msbuild -getProperty:PurviewLogicalProjectName -getProperty:RootNamespace \ + -getProperty:TestingType -getProperty:TargetProjectName -getProperty:IsTestProject +``` + +The values worth inspecting first: `MSBuildProjectName`, `NamespacePrefix`, +`PurviewLogicalProjectName`, `RootNamespace`, `TestingType`, `TargetProjectName`, `SdkProjectName`, +`IsTestProject`, `IsSharedProject`, `IsSharedTestingProject`, `IsContainerProject`, `IsCLIProject`, +`IsWebSdkProject`, `IsAspireHostProject`. + +## Detection rules + +### Test projects + +Name matches `^.*\.([\w|_]\w*)Tests?$` — a target name, a dot, then any word before `Test`/`Tests`. +The captured word becomes `TestingType`. + +This is a pattern, **not** a lookup. `MyProject.GoldenTests` is detected exactly as readily as +`MyProject.UnitTests`, with `TestingType=Golden` and `Category=Golden`, even though `Golden` is not a +name the SDK ships. + +**The dot is required.** A project named plainly `UnitTests` does not match, and silently gets no +runner, no coverage, no `InternalsVisibleTo` and no category. This is the single most common +surprise. + +Derived: `IsTestProject=true`, `TestingType=`, `PurviewTestType=Tests`, +`TargetProjectName=`. + +The SDK's explicit `TestType` item group governs **`InternalsVisibleTo` generation**, not detection: +a non-test project grants access to `{AssemblyName}.{TestType}Tests` for each name in the group. A +suffix outside it still builds, runs and gets a category — it just cannot see `internal` members. +The 53 names are in [references/test-types.md](references/test-types.md). + +### Shared and shared-testing projects + +Recognised by **exact name**, never by substring. The two lists are in +[references/detection.md](references/detection.md). Derived: `IsSharedProject`, +`IsSharedTestingProject`. + +### SDK and content based + +| Signal | Result | +| -- | -- | +| the project's `Sdk="..."` declaration | `IsSdkProject`, `SdkProjectName` | +| `Microsoft.NET.Sdk.Web` | `IsWebSdkProject=true` | +| `Microsoft.NET.Sdk.Worker` | `IsWorkerSdkProject=true` | +| SDK starts with `Aspire.Sdk.Host` or `Aspire.AppHost.Sdk` | `IsAspireHostProject=true` | +| `Dockerfile`, `dockerfile` or `Dockerfile.dev` in the project directory | `IsContainerProject=true` | +| name ends with `CLI`, `Console`, `CommandLine`, `QuickStart`, `QuickStarts` | `IsCLIProject=true` | + +## Namespace and identity + +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`. +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 + stays distinct from its parent. Test and shared-testing projects keep their detected suffix. + Explicit `AssemblyName`/`PackageId`/`RootNamespace` always win. +6. The naming defaults are applied during `Sdk.props`, before the Microsoft SDK computes + `TargetName`, so the compiled output name always matches `AssemblyName`. + +## Automatic packages and references + +- **Non-test C# projects**: SourceLink (unless `DisableSourceLink`), the Purview telemetry packages + (unless `ExcludePurviewTelemetry`), `GenerateDocumentationFile=true`, and `InternalsVisibleTo` + generation (unless `DisableAutoInternalsVisibleTo`). +- **Test and shared-testing projects**: test-friendly `NoWarn` defaults, not packable or + publishable, and the packages selected by `TestingFramework`, `SubstituteFramework` and + `TestDataFramework`. TUnit test projects get Microsoft.Testing.Platform wiring automatically. + Shared-testing projects skip the runnable test package. +- **Packable projects**, **Roslyn components** and **special project types** (CLI, container, web, + Aspire host): see [references/detection.md](references/detection.md). + +Project-reference probing is layout-driven and covered by `purview-buildsdk-project-layout`. + +## The shipped analyzers + +The SDK adds `Purview.BuildSdk.Analyzers` as an `` item to every C# project, so its rules +surface in command-line builds and in Visual Studio. The code-fix assembly ships beside it. + +| Rule | What it enforces | +| -- | -- | +| `PDS0002` | Extensions classes live under `Extensions//` | +| `PDS0003` | Explicit types with target-typed `new()` | +| `PDS0004` | Correct acronym capitalisation | + +`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 +implementations and base-class overrides — are never renamed. + +Customise through `dotnet_analyzer_configuration.pds0004.allowed_words` (exempt segments), +`.acronym_map` (segment renames, e.g. `Sql:SQL`, or `Db:DB` to re-enable it) and +`.allowed_identifiers` (brand names, exact or word-boundary prefix, first match wins — `CosmosDb` +covers `CosmosDbServer`). All three merge with and override the shipped defaults. + +The shipped `.editorconfig` marks generated content (`Migrations/`, `*.g.cs`, `Generated/`, +`*.Designer.cs`, `obj/`, `bin/`) as `generated_code = true`, and suppresses namespace-conflict +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 + +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. diff --git a/skills/purview-buildsdk-project-detection/references/detection.md b/skills/purview-buildsdk-project-detection/references/detection.md new file mode 100644 index 0000000..bf75823 --- /dev/null +++ b/skills/purview-buildsdk-project-detection/references/detection.md @@ -0,0 +1,79 @@ +# Detection detail + +## Shared project names (exact) + +`Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, +`SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers` + +## Shared testing project names (exact) + +`SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, +`SharedTestingUtilities`, `SharedTestingUtils`, `SharedTestingLibrary`, `SharedTestingLib`, +`SharedTestingHelpers` + +These are matched whole, not as substrings. `SharedModels` is not a shared project. + +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 +keep that package-driven shape. + +## Packable projects + +Defaults applied only where the project supplied nothing: `GenerateDocumentationFile`, +`IncludeSymbols`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl`, `EmbedUntrackedSources`, +`IncludeSource`, `DebugType=portable`. + +- 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. +- 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 + forces SourceLink's CI-mode dirty-repository checks. + +## Roslyn components + +A project that explicitly declares `IsRoslynComponent=true` gets: + +- a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, + `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules +- SourceLink with `EmbedUntrackedSources=true`, no dependency file, telemetry excluded +- compiler-generated output under the framework-specific intermediate directory +- `PrivateAssets=all` on `Microsoft.CodeAnalysis.*` and `Microsoft.CodeAnalysis.Analyzers`, so they + never leak into the packed nuspec + +`IsRoslynComponentOnly` defaults to `true`: `IncludeBuildOutput=false`, `IncludeSymbols=false`, no +`.symbols.nupkg` or `.snupkg`, and the analyzer PDB shipped inside the main `.nupkg` under +`analyzers/dotnet/cs/` beside the assembly (`PurviewPackAnalyzerPdb=true`). Set it to `false` for a +dual-role component that wants normal library symbol packaging. + +`ValidateRoslynComponentCompilerSettings` fails the pack of a packable Roslyn component missing +`LangVersion`, `Nullable`, `TreatWarningsAsErrors` or `EnforceExtendedAnalyzerRules`. Opt out with +`DisableRoslynCompilerDefaultsValidation=true`. + +`PackProjectReferencedSourceGenerators` (default `true`) makes a packable project include analyzer +`ProjectReference` outputs and their runtime dependencies under `analyzers/dotnet/cs/`. Set it +`false` globally, or `Pack="false"` on one analyzer reference. + +## Special project types + +| Type | Applied | +| -- | -- | +| 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` | + +## The Visual Studio refactorings + +Two ship with the code-fix assembly, both serving the `PDS0002` convention: + +- **Split extensions class into one class per receiver type** — offered on a static extensions class + 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 + to the receiver's, and updates `using` directives in other referencing documents so the move + compiles. diff --git a/skills/purview-buildsdk-project-detection/references/test-types.md b/skills/purview-buildsdk-project-detection/references/test-types.md new file mode 100644 index 0000000..dace44f --- /dev/null +++ b/skills/purview-buildsdk-project-detection/references/test-types.md @@ -0,0 +1,35 @@ +# The shipped `TestType` group + +These 53 names drive **`InternalsVisibleTo` generation**, not test detection. A non-test project +grants friend access to `{AssemblyName}.{TestType}Tests` for each name here. + +Detection is the pattern `^.*\.([\w|_]\w*)Tests?$`, so a suffix outside this group is still detected, +still runs and still gets its category — it simply cannot reach the target project's `internal` +members. Prefer a name from this group unless the test type genuinely needs its own word. + +```text +Unit Integration E2E EndToEnd Acceptance +Functional Performance Load Smoke Stress +Regression Security Chaos Scenario System +Threat BlackBox WhiteBox GrayBox GreyBox +Accessibility Usability Visual Interactive Localization +Globalization Environment Configuration Deployment Architecture +Contract Component Sanity Property Fuzz +Mutation Snapshot Approval Benchmark Capacity +Concurrency Endurance Soak Spike Failover +Recovery Resilience Compatibility Interoperability Migration +Upgrade Penetration Compliance +``` + +## The five to reach for first + +`Unit`, `Integration`, `E2E`, `Functional`, `Contract`. Everything else is available, but a reader +has to ask what it means — use one only when the test type itself carries operational weight. + +## What a suffix buys you + +| | detected as a test project | gets a category | gets a runner and coverage | sees `internal` members | +| -- | -- | -- | -- | -- | +| `Api.UnitTests` | yes | `Unit` | yes | yes | +| `Api.GoldenTests` | yes | `Golden` | yes | **no** | +| `UnitTests` *(no dot)* | **no** | no | **no** | no | diff --git a/skills/purview-buildsdk-project-layout/SKILL.md b/skills/purview-buildsdk-project-layout/SKILL.md new file mode 100644 index 0000000..55a019c --- /dev/null +++ b/skills/purview-buildsdk-project-layout/SKILL.md @@ -0,0 +1,136 @@ +--- +name: purview-buildsdk-project-layout +description: Decide where a project goes and what to call it in a repository that uses Purview.BuildSdk. Use when creating, moving, splitting or renaming a project or test project, choosing a test suffix, aligning namespaces and assembly identity with NamespacePrefix, or relying on the SDK's automatic project references. Also use when rationalising a repository's shape so the SDK's conventions work without overrides. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Project layout and naming for Purview.BuildSdk + +Use this skill when physically adding, moving, splitting or renaming a project. + +For *why* the SDK classified an existing project a particular way, use +`purview-buildsdk-project-detection`. For the property that changes a behaviour, use +`purview-buildsdk-configuration`. + +## First principle + +**Treat naming and placement as configuration, not decoration.** + +The SDK infers namespaces, assembly identity, test categories, package wiring and project references +from a small set of conventions. The closer a repository sits to them, the fewer overrides it needs. + +The corollary matters just as much: **preserve the host repository's existing layout first.** Only +introduce new structure when no established pattern exists. + +## Canonical layout + +```text +/ + package.json version source + .editorconfig bootstrapped by the SDK + skills/ authored agent skills, if any + src/ + .slnx + src//.csproj + tests/.Tests/.Tests.csproj +``` + +- The `.csproj` filename must equal its containing directory name, unless + `DisableProjectFileNamingConventionCheck=true`. +- Source projects under `src/`, test projects under `tests/`. +- Place a new project beside similar ones — same language, layer and test type. +- One test type per project by default. + +A repository that already uses a different shape keeps it; add to it rather than converting it. + +## Naming and identity + +`NamespacePrefix` is the root identity source. Keep project names short and let the SDK apply the +prefix: + +| `NamespacePrefix` | Project | `RootNamespace` | +| -- | -- | -- | +| `Aspire` | `Hosting` | `Aspire.Hosting` | +| `Acme.Sales.RegionalPipeline` | `Identity.API` | `Acme.Sales.RegionalPipeline.Identity.API` | +| `Acme.Sales.RegionalPipeline` | `Identity.Core` | `Acme.Sales.RegionalPipeline.Identity` | + +`AssemblyName` and `PackageId` follow the resolved `RootNamespace` — except where suffix-stripping +removed a segment (`Core`, `Shared`, `ServiceDefaults`), in which case they keep the fuller logical +name so the artifacts stay distinct from their parent. Explicit values always win, and +`EnableAssemblyNameGeneration=false` (before the SDK import) restores standard behaviour. + +Do not hand-author an alternate namespace scheme when one is already in use. + +## Test project naming + +Test detection is a **pattern**, not a list: `^.*\.([\w|_]\w*)Tests?$`. A target name, a dot, then +any word before `Test` or `Tests`. The captured word becomes `TestingType` and the baseline category. + +**The dot is required.** A project named plainly `UnitTests`, with no target in front of it, is not +detected as a test project at all — no runner, no coverage, no `InternalsVisibleTo`, no category. +Always name a test project `.Tests`. + +Prefer these by default: `UnitTests`, `IntegrationTests`, `E2ETests`, `FunctionalTests`, +`ContractTests`. Use another suffix only when the test type itself carries operational meaning. + +A suffix outside the SDK's shipped `TestType` group still builds, runs and gets its category — it +just cannot reach the target project's `internal` members, because that group drives +`InternalsVisibleTo` generation. The full list is in `purview-buildsdk-project-detection`. + +## Shared projects: exact names only + +The SDK recognises these names exactly — a near miss gets none of the behaviour. + +Shared: `Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, +`SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers`. + +Shared testing: `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, +`SharedTestingUtilities`, `SharedTestingUtils`, `SharedTestingLibrary`, `SharedTestingLib`, +`SharedTestingHelpers`. + +## Automatic project references to preserve + +Layout is what makes these work. Move a project off the convention and you own the reference. + +Test projects probe for their target, in order: + +- `../$(TargetProjectName)/$(TargetProjectName).csproj` +- `../../$(TargetProjectName)/$(TargetProjectName).csproj` +- `../src/$(TargetProjectName)/$(TargetProjectName).csproj` +- `../../src/$(TargetProjectName)/$(TargetProjectName).csproj` + +Non-test projects pick up sibling shared projects via `../Shared*/Shared*.csproj`; test projects +pick up `../SharedTesting*/SharedTesting*.csproj`. + +## Creating a new project + +1. Match the SDK and style of sibling projects. +2. Use Central Package Management if the repository has it. +3. Add only the dependencies the project's scope needs — the SDK supplies the testing stack, + SourceLink and telemetry by default. +4. Add the project to the solution. +5. Keep target frameworks, nullability, analyzers and warning settings consistent with neighbours. + +## Moving or splitting + +1. Create the destination using the established layout. +2. Move the files. +3. Update namespaces, usings and references for the destination. +4. Verify the destination still produces the intended `TestingType`, `TargetProjectName` and + `RootNamespace` — ask MSBuild rather than assuming: + `dotnet msbuild -getProperty:RootNamespace -getProperty:TargetProjectName`. +5. Remove dependencies the source no longer needs. +6. Update solution membership and project references. +7. Build, then run the affected tests. + +## Guardrails + +- Prefer minimal, targeted diffs; avoid cross-cutting renames unrelated to the move. +- Keep test intent unchanged while relocating. +- When the structure is ambiguous, infer from the nearest sibling projects and say which assumption + you made. +- When in doubt, preserve compatibility with the SDK's automatic naming, namespace and + project-reference behaviour. diff --git a/skills/purview-buildsdk-repository-review/SKILL.md b/skills/purview-buildsdk-repository-review/SKILL.md new file mode 100644 index 0000000..ce1fd29 --- /dev/null +++ b/skills/purview-buildsdk-repository-review/SKILL.md @@ -0,0 +1,111 @@ +--- +name: purview-buildsdk-repository-review +description: Review a repository that uses Purview.BuildSdk for naming, placement and test-structure alignment, and report what the SDK can and cannot do automatically as a result. Use when asked to audit, rationalise or health-check a repo against the SDK's conventions, when onboarding an existing codebase onto Purview.BuildSdk, or when deciding whether a layout quirk is worth changing. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Reviewing a repository against the SDK's conventions + +This is an audit, not a refactor. The deliverable is a prioritised list of findings and the cost of +each, so somebody else can decide what to change. + +For the conventions themselves use `purview-buildsdk-project-layout`; for why one project was +classified as it was, `purview-buildsdk-project-detection`. + +## The governing judgement + +**Deviation is only a finding when it costs the repository something.** The SDK infers behaviour +from naming and placement, so a deviation is interesting precisely to the extent that it turns an +automatic behaviour off. A layout that differs from the canonical one but still gets every automatic +behaviour is not debt, and reporting it as such wastes the reader's attention. + +Sort every finding into one of three buckets, and say which: + +| Bucket | Meaning | +| -- | -- | +| **Acceptable variance** | Differs from the canonical layout, costs nothing. Preserve it. | +| **Structural debt** | Blocks an automatic behaviour the repository would benefit from. | +| **Incremental opportunity** | Low-risk tidying worth doing when the area is touched anyway. | + +## 1. Read before concluding + +Inspect, in this order, before forming any opinion: + +- `Directory.Build.props` and `Directory.Build.targets` — where the SDK is imported, and what is set + before the import +- the solution entry point, for what is actually in the build +- `Directory.Packages.props`, for Central Package Management +- the project layout on disk + +## 2. Check the structural signals + +- Source projects under `src/`, test projects under `tests/`. +- `.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. +- Test project names match `^.*\.([\w|_]\w*)Tests?$`. **The dot is required**; a project named + plainly `UnitTests` is not detected at all and silently gets no runner, no coverage, no + `InternalsVisibleTo` and no category. This is the single highest-value thing to check. +- Test suffixes come from the SDK's shipped `TestType` group where `internal` access is wanted — a + suffix outside it still runs, it just cannot see `internal` members. +- Shared and shared-testing projects use the **exact** recognised names. A near miss gets none of + the behaviour. + +## 3. Verify with MSBuild, do not infer from folder names + +A review that reasons from directory names will be wrong about at least one project. Ask: + +```bash +dotnet msbuild -getProperty:RootNamespace -getProperty:AssemblyName \ + -getProperty:IsTestProject -getProperty:TestingType -getProperty:TargetProjectName +``` + +Quote the computed values in the findings. "`Api.Tests` resolves `TestingType=` empty, so it has no +runner" is actionable; "`Api.Tests` looks like it might not be detected" is not. + +## 4. Review test readability + +- Subject-based suites: `{SubjectName}Tests`, with + `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}` methods. +- Non-subject-based suites — build, generator, workflow, package, full-system — named clearly for + their broader role. A forced `{Subject}Tests` on one of these is worse, not better. +- TUnit categories and display names used where a suite is large enough to need navigating. + +See `purview-buildsdk-testing`. + +## 5. Check the test dependencies are earned + +The SDK already supplies `TUnit`, `TUnit.Mocks`, `Bogus` and the Microsoft.Testing.Platform wiring +to every detected test project. Anything beyond that was a deliberate choice, and deliberate choices +go stale: + +- `TUnit.Aspire` and `Purview.Containers` are **not** universal defaults. Confirm each reference is + genuinely needed by that project rather than copied from a sibling. +- Where a project references `Testcontainers` directly, check whether a `Purview.Containers` typed + module now covers it — the module set grows, and a direct reference that was once necessary often + is not any more. +- A package the SDK already adds, restated in a project file, is maintenance cost with no effect. + +## 6. Explain consequences, not just mismatches + +For each finding, name the specific automatic behaviour that is lost: `RootNamespace`, +`AssemblyName`, `PackageId`, `TargetProjectName`, the automatic target-project reference, the +sibling `Shared*` reference, `InternalsVisibleTo` generation, the test runner and coverage wiring. + +"Project is not under `src/`" is not a finding. "Project is not under `src/`, so the test project's +four probe paths all miss and the `ProjectReference` has to be maintained by hand" is. + +## Reporting the result + +1. One sentence on whether the repository broadly fits the SDK's conventions. +2. The findings, ordered by impact, each with its bucket and the behaviour it costs. +3. The low-risk rationalisation steps, as concrete renames or moves. +4. An explicit split of what is **already automatic** versus what this repository has configured by + hand — that list is usually the most useful part of the review, because hand-configuration that + merely restates a default is pure maintenance cost. + +Do not propose a cross-cutting rename as a first step. Name the one change with the best +cost-to-benefit ratio and say what it unlocks. diff --git a/skills/purview-buildsdk-skill-installation/SKILL.md b/skills/purview-buildsdk-skill-installation/SKILL.md new file mode 100644 index 0000000..66c20a3 --- /dev/null +++ b/skills/purview-buildsdk-skill-installation/SKILL.md @@ -0,0 +1,166 @@ +--- +name: purview-buildsdk-skill-installation +description: Install, list and diagnose the agent skills a Purview.BuildSdk package ships. Use for the InstallPurviewSkills, ListPurviewSkills and RecommendPurviewSkills MSBuild targets, for any PRSKL0001-PRSKL0014 diagnostic, for choosing a package runner (npx, pnpm dlx, bunx, yarn dlx, deno), for skills-lock.json, or when a package ships skills but no agent can see them. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Installing the skills a package ships + +For *authoring* skills, use `purview-buildsdk-skill-tests`. + +## 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. + +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. + +```bash +dotnet msbuild -t:ListPurviewSkills # diagnostics only, installs nothing +dotnet msbuild -t:InstallPurviewSkills # delegates to the skills CLI +``` + +Neither target has `BeforeTargets` or `AfterTargets`. If skills are appearing or changing on their +own, something has been wired up that should not be. + +## How a skill is resolved, and why the version is always right + +There is deliberately **no custom manifest**. A package that ships skills already carries both +halves of the mapping, and `ResolvePurviewSkillSources` reads them: + +- `.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. + +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. + +## 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=` | +| `PRSKL0004` | No skills CLI and no package runner found | Install Node, or set `PurviewSkillsCommand` | +| `PRSKL0005` | An authored skill is malformed | See `purview-buildsdk-skill-tests` | +| `PRSKL0010` | A skill a referenced package ships is missing | Warning. `npx 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 | + +## Recommendations run during an ordinary build + +This is the **one** skill feature that does. `RecommendPurviewSkillsOnBuild` runs after `Build` and +delegates to `RecommendPurviewSkills`, which can also be invoked by name: + +```bash +dotnet msbuild -t:RecommendPurviewSkills +``` + +`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. + +Four things keep it quiet, and all of them are load-bearing: + +- Design-time builds, CI builds, non-default TFM passes and + `EnablePurviewSkillRecommendations=false` are skipped outright. + `PurviewSkillRecommendationsInCI=true` re-enables CI. +- A fingerprint stamp at `/.purview/skill-recommendations.cache` means a given state is + reported once per repository, not once per project and again on every rebuild. The explicit target + ignores the stamp. +- Missing and drifted are **warnings** (fixable in one command); unverifiable and orphaned are + **messages**. No amount of skill advice can break a build using `TreatWarningsAsErrors`. +- A correctly installed set produces no output at all. + +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 `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. + +## Package runners + +The CLI is published to npm, and every runner spells "run this npm package" differently. +`PurviewSkillsRunner` selects one, defaulting to `npx` because npx ships with Node itself. + +| Runner | Invocation | +| -- | -- | +| `npx` | `npx -y skills@1` | +| `bunx` | `bunx skills@1` | +| `bun` | `bun x skills@1` | +| `pnpm` | `pnpm dlx skills@1` | +| `yarn` | `yarn dlx skills@1` — Yarn 2+ only | +| `deno` | `deno run -A npm:skills@1` | + +An unknown runner falls through to ` $(PurviewSkillsPackage)`, so it still works. + +`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. + +## Always the repository root + +`skills-lock.json` and the agent directories belong to the repository, not to a project, so every +skill target runs from one resolved root: + +1. `PurviewSkillsInstallRoot` — an explicit override +2. `PurviewSkillsRepoRoot` — only when the **user** set it +3. the `PurviewRepositoryRootMarkers` walk +4. `PurviewSkillsRepoRoot` / `RepoRoot` as the SDK guessed them +5. CI workspace variables +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. + +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. + +## 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 `npx skills experimental_install`. + +`PurviewSkillsAgents` (default `*`) narrows the target agents. + +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. diff --git a/skills/purview-buildsdk-skill-installation/references/repository-root.md b/skills/purview-buildsdk-skill-installation/references/repository-root.md new file mode 100644 index 0000000..e90b695 --- /dev/null +++ b/skills/purview-buildsdk-skill-installation/references/repository-root.md @@ -0,0 +1,55 @@ +# The repository-root marker walk + +`FindPurviewRepositoryRoot` is an inline MSBuild task driven by `PurviewRepositoryRootMarkers`: + +```text +.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json +``` + +A trailing slash marks a directory. A directory marker still matches a **file** of that name, +because `.git` is a directory in a clone but a file in a worktree or a submodule, and both identify +a repository root. + +## Three things that are load-bearing + +### It cannot be an MSBuild intrinsic + +`GetDirectoryNameOfFileAbove` matches **files only** and returns nothing for `.git/`, `.github/`, +`.agents/` or `.claude/` — four of the nine markers. Verified, not assumed. + +### Two passes + +- **Pass 1** — the first marker in the list is *definitive* and is searched all the way up. `.git/` + *is* the repository root, so a monorepo sub-package with its own `package.json` does not win. +- **Pass 2** — every other marker resolves nearest-ancestor-first, with list order breaking ties + inside a single directory. Searching them all to the top would let a stray `.claude/` in a + developer's home directory outrank the real root. + +### The home directory is never a candidate + +The home directory and everything above it are excluded. Without that bound, a project outside any +repository resolves to `~` and installs into it — and a `.claude/` or a `.github/` very commonly +sits there. A repository *below* the home directory is unaffected, because it is below it. + +## Why the marker walk outranks the evaluation-time guess + +`PurviewSkillsRepoRoot` is computed during evaluation from `RepoRoot` and an `AGENTS.md` walk-up. +An `AGENTS.md` can legitimately sit in a subdirectory, and resolving to it would put a lockfile and +an agent directory somewhere that is not the repository. + +`_PurviewSkillsRepoRootWasSpecified` is captured in `Sdk.props` *before* defaulting, so a value the +**user** set still outranks the walk while a value the SDK guessed does not. + +## 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 equally why the build hook's CI and design-time gating works at all. + +## The managed port + +`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. diff --git a/skills/purview-buildsdk-skill-tests/SKILL.md b/skills/purview-buildsdk-skill-tests/SKILL.md new file mode 100644 index 0000000..bc6e864 --- /dev/null +++ b/skills/purview-buildsdk-skill-tests/SKILL.md @@ -0,0 +1,130 @@ +--- +name: purview-buildsdk-skill-tests +description: Author agent skills in a repository that uses Purview.BuildSdk, and validate them with Purview.Skills.Testing or the SDK's pack-time ValidatePurviewSkills target. Use when adding or renaming a skill, writing SKILL.md frontmatter, splitting content into references/ or scripts/, fixing a PRSKL0005 pack failure or a skill-name, skill-description or skill-bundled-resources test failure, or deciding where skills belong in a repository. +license: MIT +metadata: + package: Purview.Skills.Testing + repository: purview-dev/build-sdk +--- + +# Authoring and validating agent skills + +For installing skills a package ships, use `purview-buildsdk-skill-installation`. + +## Where skills live + +**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. + +```text +skills/ + my-skill/ + SKILL.md required + references/ loaded on demand + scripts/ executable, run on demand + assets/ templates and static resources +``` + +Link to a skill by its **authored** path, `./skills//SKILL.md`. A link into `.agents/skills/` +points at a gitignored file that does not exist in a fresh clone. + +## `SKILL.md` format + +```markdown +--- +name: my-skill +description: What it does, and when to use it. Include the words a task would contain. +license: MIT +metadata: + package: My.Package +--- + +# My skill + +Instructions. +``` + +| Field | Required | Constraint | +| -- | -- | -- | +| `name` | yes | 1–64 chars, lowercase `a-z0-9` and single hyphens, no leading, trailing or consecutive hyphen, **must equal the folder name** | +| `description` | yes | 1–1024 chars, non-empty | +| `license` | no | A licence name, or the name of a bundled licence file | +| `compatibility` | no | ≤500 chars, only when the skill has real environment requirements | +| `metadata` | no | A map of string to string. Anything not defined above belongs here | +| `allowed-tools` | no | Space-separated pre-approved tools. Experimental | + +**There are no other top-level fields.** `category`, `roles` and `tags` are common inventions and a +strict client rejects them; put them under `metadata`. + +The full specification is at . +[references/authoring.md](references/authoring.md) covers description writing, naming for a shared +namespace, and progressive disclosure. + +## Progressive disclosure + +An agent loads a skill in three stages: the `name` and `description` at startup, the whole +`SKILL.md` body once the skill activates, and `references/`, `scripts/` or `assets/` only when the +task reaches them. + +So: keep `SKILL.md` **under 500 lines**, and move detail into `references/`. The body is a cost paid +on every activation; a reference file is paid only when it is needed. + +Keep file references one level deep from `SKILL.md`, and relative to the skill root. A path that +climbs out of the skill folder is broken everywhere except the authoring repository, because only +the skill's own folder is installed. + +## Validating + +### In tests — `Purview.Skills.Testing` + +```csharp +[InheritsTests] +public sealed class SkillTests : PurviewSkillTests; +``` + +**`[InheritsTests]` is load-bearing.** TUnit does not walk base types during discovery, so without +it the class compiles, the run is green and nothing is validated. TUnit reports `TUnit0030`; treat +it as an error. + +| Rule id | What it checks | +| -- | -- | +| `skills-discovery` | The folder, when present, holds at least one skill | +| `skill-structure` | Every folder has a `SKILL.md` whose frontmatter parses | +| `skill-name` | Spec-legal, unique, and equal to the folder name | +| `skill-description` | Present, non-empty, within 1024 characters | +| `skill-optional-fields` | Only spec-defined fields, each within its limit | +| `skill-bundled-resources` | Every linked file exists, inside the skill folder | +| `skill-body-size` | `SKILL.md` stays under the recommended 500 lines | + +A repository with **no** `skills/` folder passes every rule — authoring skills is optional. An +**empty** folder fails `skills-discovery`, because every other rule is vacuous against an empty +catalogue. + +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 as `purview-buildsdk-architecture-tests`. + +### At pack time — `ValidatePurviewSkills` + +It runs `BeforeTargets="Pack"`, so a malformed skill fails in the repository that owns it rather +than reaching the CLI, which would silently skip it. + +```bash +dotnet msbuild -t:ValidatePurviewSkills +``` + +Failures are reported as `PRSKL0005`, one per problem. Every authored skill is checked. + +The two checks overlap deliberately: the pack-time one runs without a test host, the test one +covers more of the specification. + +## What is deliberately not checked + +Prose quality, and whether an agent actually obeys the skill. A build can only check that a skill is +well formed. Treat a green run as "installable", not "good". diff --git a/skills/purview-buildsdk-skill-tests/references/authoring.md b/skills/purview-buildsdk-skill-tests/references/authoring.md new file mode 100644 index 0000000..69d4ee4 --- /dev/null +++ b/skills/purview-buildsdk-skill-tests/references/authoring.md @@ -0,0 +1,79 @@ +# Authoring a skill that fires, and keeps firing + +## The description is the whole activation decision + +`name` and `description` are the only things loaded at startup. Everything else — the body, the +references, the scripts — is invisible until an agent has already decided to activate the skill on +the strength of those two fields. + +So write the description for a *matcher*, not for a reader: + +- Say **what it does** and **when to use it**, in that order. +- Include the words a real task would contain: the tool names, the file names, the error codes, the + property names, the phrases a person would type. +- Name the thing the skill is *not*, when a neighbouring skill could be confused with it. + +| | | +| -- | -- | +| Poor | `Helps with the build SDK.` | +| Better | `Configure Purview.BuildSdk from Directory.Build.props or a .csproj. Use when setting NamespacePrefix, choosing a testing framework, or working out whether a property must be set before the SDK import.` | + +The limit is 1024 characters. Most of that budget is better spent on trigger words than on prose. + +## Naming for a shared namespace + +Skills install into one flat directory per agent, shared with every other skill a developer has +installed from anywhere. A generic name is a collision waiting to happen, and the loser is whichever +one installed second. + +- Prefix with the vendor and the product: `purview-buildsdk-configuration`, not `sdk-configuration`. +- `sdk-*`, `project-*`, `test-*`, `build-*` are all too common to use unprefixed. +- The name must be 1–64 characters, lowercase `a-z0-9` with single hyphens, no leading, trailing or + consecutive hyphen — and it must equal the folder name, because the CLI installs by the + frontmatter name while a human reads the folder. + +Renaming a skill means renaming the folder and the frontmatter together, and re-running the install. + +## Progressive disclosure in practice + +| Stage | Loaded | Budget | +| -- | -- | -- | +| Metadata | `name`, `description` | ~100 tokens, always | +| Instructions | the whole `SKILL.md` body | under 5000 tokens recommended; keep the file under 500 lines | +| Resources | `references/`, `scripts/`, `assets/` | only when the task reaches them | + +What belongs where: + +- **`SKILL.md`** — the decisions, the rules, the things that are wrong by default, and a pointer to + everything else. If a reader needs it to make the *first* correct move, it goes here. +- **`references/`** — long tables, full option lists, grammars, enumerations. Keep each file + focused; an agent loads the whole file. +- **`scripts/`** — executable code. Self-contained, or documenting its dependencies clearly, with + helpful error messages. +- **`assets/`** — templates, schemas, images, lookup data. + +Keep references one level deep. Avoid a chain where one reference sends an agent to another. + +## Links + +Use relative paths from the skill root: + +```markdown +See [the reference](references/REFERENCE.md) for detail. +Run `scripts/extract.py`. +``` + +Only the skill's own folder is installed, so a link that climbs out of it (`../other-skill/...`) is +broken on every machine except the one that authored it. To point at another skill, name it in prose +and let the agent find it by name. + +## Keeping a skill true + +A skill that describes a product goes stale the moment the product changes, and nothing about a +stale skill looks broken — it just gives confidently wrong advice. + +- Change the skill in the **same commit** as the behaviour it documents. +- Prefer stating the property name, the diagnostic code or the target name over paraphrasing the + 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. diff --git a/skills/purview-buildsdk-testing/SKILL.md b/skills/purview-buildsdk-testing/SKILL.md new file mode 100644 index 0000000..5a5a7d5 --- /dev/null +++ b/skills/purview-buildsdk-testing/SKILL.md @@ -0,0 +1,121 @@ +--- +name: purview-buildsdk-testing +description: Write and run tests in a repository that uses Purview.BuildSdk. Use when authoring or refactoring TUnit tests (AAA structure, Subject_Scenario_Expectation naming, awaited assertions, CancellationToken), when choosing a test project's framework packages, and when running or filtering tests - the SDK wires Microsoft.Testing.Platform, so `dotnet test --filter` reports "Zero tests ran" and `--treenode-filter` is the one that works. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Testing in a Purview.BuildSdk repository + +Two things to know before anything else: + +1. A test project is detected by **name**, and detection is what wires the runner. See + `purview-buildsdk-project-layout` if the project is new or being moved. +2. The runner is **Microsoft.Testing.Platform (MTP)**, not VSTest. `--filter` does nothing useful. + +## What a test project gets automatically + +With the defaults (`TestingFramework=TUnit`, `SubstituteFramework=TUnitMocks`, +`TestDataFramework=Bogus`), a detected test project receives: + +- `TUnit`, `TUnit.Mocks`, `Bogus` +- Microsoft.Testing.Platform integration +- `CollectCoverage=true` +- test-friendly `NoWarn` defaults (`PurviewTestContextNoWarn`), while keeping the strict style + contract — `IDE0040`, field naming, formatting, `IDE1006` +- `IsPackable=false`, `IsPublishable=false` + +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. + Reference `Testcontainers` directly only where no typed module exists yet. + +## Authoring rules + +- Use TUnit (`TUnit.Core`, `TUnit.Assertions`). Do not introduce xUnit, NUnit, MSTest or + FluentAssertions unless explicitly asked. +- Every test method carries `[Test]`. +- **Every assertion is awaited**: `await Assert.That(actual).IsEqualTo(expected);`. An un-awaited + assertion silently passes. +- Arrange / Act / Assert, with the `// Arrange`, `// Act`, `// Assert` comments present. +- Test classes are `{SubjectName}Tests`, in the **same namespace** as the subject. +- Test methods are `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}`. Use `ctor` as the subject + for constructor tests. Methods, constructors, properties, operators, conversions and validation + hooks are all valid subjects. +- When any method called in the test body accepts a `CancellationToken`, take + `CancellationToken cancellationToken` as the **last parameter** and pass it through. + +```csharp +[Test] +public async Task ProcessAsync_GivenAnEmptyBatch_ReturnsZero(CancellationToken cancellationToken) +{ + // Arrange + var sut = new BatchProcessor(); + + // Act + var result = await sut.ProcessAsync([], cancellationToken); + + // Assert + await Assert.That(result).IsEqualTo(0); +} +``` + +For a suite that is not subject-based — a build, generator, workflow, package or full-system suite — +a broader, more truthful class name is better than a forced `{Subject}Tests`. Use TUnit display +names and categories to keep it navigable. + +When refactoring existing tests, bring them into compliance with all of the above while preserving +test intent. + +## Categories + +The detected test type becomes the baseline category automatically — `Api.IntegrationTests` yields +`Category=Integration`. Add further categories when they make a large suite easier to navigate; +they are what `--treenode-filter` property predicates select on. + +## Running tests + +```bash +dotnet test # the whole suite +dotnet test --treenode-filter "/*/*/LoginTests/*" # one class +dotnet test --treenode-filter "/*/*/*/*[Category=Unit]" +``` + +Prefer the repository's own task (`just test`, or equivalent) for a full run. + +The filter path is `////`, with `*` wildcards, `=`/`!=` property +predicates, `&`/`|` inside a single bracket group, and `**` only at the end. The full grammar, the +`--` separator rules across SDK versions, and the zero-tests checklist are in +[references/treenode-filter.md](references/treenode-filter.md). + +### "Zero tests ran" + +In order: + +1. Are you using `--treenode-filter` and not `--filter`? This is the cause most of the time. +2. Try `dotnet test --treenode-filter "..."`; on an older SDK use `dotnet test -- --treenode-filter "..."`. +3. Broaden to `/*/*/*/*`, then narrow one segment at a time. +4. Does the project actually contain `[Test]` methods? +5. Is the project **detected** as a test project? A name without the dot — `UnitTests` rather than + `Api.UnitTests` — gets no runner at all. Check with + `dotnet msbuild -getProperty:IsTestProject`. +6. Are all property predicates inside one bracket group, `[(A=1)&(B=2)]`? + +## Cross-platform requirement + +CI runs the full suite on Linux, so a test that only passes on Windows is a failing change. + +- Never hardcode a platform-specific path — no `C:\...`, no backslash-only separators, no + case-insensitive path assumptions. +- Build paths with `Path.Combine`, `Path.GetTempPath()`, `Path.DirectorySeparatorChar`, + `Path.GetFullPath()`. +- Feed production path-math code only native-format paths. Normalise a fixed Windows-style literal + to the current platform before passing it in. +- Do not rely on a case-insensitive filesystem, a `C:` drive, or trailing-separator behaviour. +- Keep `SyntaxTree` file paths and `build_property.*` values consistent and native when writing + analyzer tests. diff --git a/skills/purview-buildsdk-testing/references/treenode-filter.md b/skills/purview-buildsdk-testing/references/treenode-filter.md new file mode 100644 index 0000000..35d032b --- /dev/null +++ b/skills/purview-buildsdk-testing/references/treenode-filter.md @@ -0,0 +1,107 @@ +# `--treenode-filter` reference + +TUnit runs on Microsoft.Testing.Platform, not VSTest, so the VSTest `--filter` syntax does not +apply. Narrowing is done with `--treenode-filter`. + +## The path + +```text +//// +``` + +Each segment maps to one level of the tree: + +| Position | Matches | +| -- | -- | +| 1 | Assembly | +| 2 | Namespace | +| 3 | Class | +| 4 | Test name | + +```text +/*/*/LoginTests/* every test in LoginTests +/*/*/*/AcceptCookiesTest one test by name +/*/MyProject.Tests.Integration/*/* every test in a namespace +/*/*/*/* everything +``` + +## Operators + +### `*` — wildcard + +Matches a whole segment or part of one. + +```text +/*/*/LoginTests*/* +/*/*/MyProject.Tests.Api*/* +``` + +### `=` and `!=` — property predicates + +In the last segment, inside `[...]`. + +```text +/*/*/*/*[Category=Unit] +/*/*/*/*[Category!=Slow] +/*/*/*/*[Owner=*Team-Backend*] +``` + +### `&` and `|` — combining + +Inside a **single** bracket group, or a single parenthesised segment. + +```text +/*/*/*/*[(Category=Unit)&(Priority=High)] +/**[(Category=Unit)|(Priority=High)] +/*/*/(LoginTests)|(SignupTests)/* +``` + +### `**` — match all + +Any depth, but **only at the end** of the path. + +```text +/** +/MyAssembly/** +``` + +`/**/Path` is not valid. + +## Rules that catch people out + +- Only **one** property group `[...]` per path segment. `[Category=Smoke]|[Priority=High]` is not + valid; write `[(Category=Smoke)|(Priority=High)]`. +- `**` must be last. +- A segment left empty is not a wildcard — write `*`. + +## The `--` separator + +```bash +dotnet test --treenode-filter "/*/*/*/*[Category=Unit]" # preferred +dotnet test -- --treenode-filter "/*/*/*/*[Category=Unit]" # older SDKs +``` + +Newer .NET SDKs forward unrecognised arguments to the MTP runner directly. On an older SDK the `--` +separator is required, and omitting it produces an argument error rather than a filter. + +## Common selections + +| Intent | Filter | +| -- | -- | +| Everything | `/*/*/*/*` | +| All unit tests | `/*/*/*/*[Category=Unit]` | +| High-priority unit tests | `/*/*/*/*[(Category=Unit)&(Priority=High)]` | +| One class | `/*/*/MyFeatureTests/*` | +| A name pattern | `/*/*/*/MyScenario*` | +| Critical integration tests | `/*/MyProject.Tests.Integration/*/*[Priority=Critical]` | + +## Zero tests ran + +1. `--treenode-filter`, not `--filter`. +2. Try without the `--` separator first, then with it. +3. Broaden to `/*/*/*/*`, then narrow one segment at a time. +4. Confirm the project has `[Test]` methods. +5. Confirm the project is **detected** as a test project — + `dotnet msbuild -getProperty:IsTestProject`. A name without the dot, such as + `UnitTests` rather than `Api.UnitTests`, is not detected and gets no runner. +6. Confirm every property predicate sits in one bracket group. diff --git a/src/BuildSdk.slnx b/src/BuildSdk.slnx index b77fae8..08661e9 100644 --- a/src/BuildSdk.slnx +++ b/src/BuildSdk.slnx @@ -5,17 +5,22 @@ +
+ + + + diff --git a/src/src/ArchitectureTesting/ArchitecturePolicy.cs b/src/src/ArchitectureTesting/ArchitecturePolicy.cs new file mode 100644 index 0000000..3f58a43 --- /dev/null +++ b/src/src/ArchitectureTesting/ArchitecturePolicy.cs @@ -0,0 +1,234 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Purview.Architecture.Testing; + +/// +/// The dependency and naming policy, read from .config/architecture-policy.json. +/// +/// +/// The policy file is the single definition of each rule. The tests read it, and +/// PolicyDocumentationTests asserts the wiki agrees with it, so a rule cannot be true in prose +/// and false in code. See docs/wiki/Project-Responsibilities.md. +/// +public sealed class ArchitecturePolicy +{ + static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + }; + + [JsonPropertyName("version")] + public int Version { get; init; } + + /// + /// Repo-relative folders holding the projects. An empty array scans the repository root. + /// + /// + /// Defaults to src, which is the Purview layout. A repository with a different shape must + /// set this, or discovery finds nothing - which the GraphDiscovery rule reports rather than + /// letting every other rule pass vacuously. + /// + [JsonPropertyName("sourceRoots")] + public IReadOnlyList SourceRoots { get; init; } = ["src"]; + + [JsonPropertyName("responsibilities")] + public IReadOnlyList Responsibilities { get; init; } = []; + + [JsonPropertyName("discouragedProjectNames")] + public DiscouragedNames DiscouragedProjectNames { get; init; } = new(); + + [JsonPropertyName("rules")] + public PolicyRules Rules { get; init; } = new(); + + [JsonPropertyName("waivers")] + public IReadOnlyList Waivers { get; init; } = []; + + /// Finds the documented waiver for a project, if one exists. + /// The project name. + /// The waiver, or . + public PolicyWaiver? WaiverFor(string projectName) => + Waivers.FirstOrDefault(waiver => + string.Equals(waiver.Project, projectName, StringComparison.OrdinalIgnoreCase) + ); + + /// The policy file's location relative to a repository root. + public static string RelativePath { get; } = Path.Combine(".config", "architecture-policy.json"); + + /// Reads the policy from a repository's configuration folder. + /// The repository root, or to probe upward for one. + /// The policy. + /// No policy file exists at the expected location. + public static ArchitecturePolicy Load(string? repositoryRoot = null) + { + var path = Path.Combine(repositoryRoot ?? RepositoryRoot(), RelativePath); + if (!File.Exists(path)) + { + throw new FileNotFoundException( + $"The architecture policy was not found at '{path}'. Add one, pointing its $schema at " + + "https://raw.githubusercontent.com/purview-dev/build-sdk/main/architecture-policy.schema.json", + path + ); + } + + return JsonSerializer.Deserialize(File.ReadAllText(path), SerializerOptions) + ?? throw new InvalidOperationException($"The architecture policy at '{path}' deserialised to null."); + } + + /// Finds the repository root by walking up to the folder holding the policy. + /// The repository root path. + /// + /// The test binary runs from bin/, and the depth of that path differs between a local build + /// and CI, so the root is found by probing for a marker rather than by counting parents. + /// + public static string RepositoryRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, RelativePath))) + return directory.FullName; + + // Stop at the repository boundary. Walking past it would happily find a policy belonging + // to a parent folder - a sibling checkout, or a stray file in the directory everything is + // cloned into - and silently validate this repository against someone else's rules. + if ( + Directory.Exists(Path.Combine(directory.FullName, ".git")) + || File.Exists(Path.Combine(directory.FullName, ".git")) + ) + { + throw new InvalidOperationException( + $"No architecture policy was found in the repository at '{directory.FullName}'. " + + $"Add '{RelativePath}', or override RepositoryRoot on the test class. " + + "The search deliberately stops at the repository boundary rather than continuing into parent folders." + ); + } + + directory = directory.Parent; + } + + throw new InvalidOperationException( + $"Could not locate the repository root above '{AppContext.BaseDirectory}'. Expected a '.config/architecture-policy.json'." + ); + } + + /// Finds the responsibility a project name maps to, honouring aliases. + /// The project name, which may be implementation-qualified. + /// The responsibility, or when the name is not recognised. + /// + /// A project name is <Responsibility>[.<Implementation>], so only the first + /// segment is classified: Persistence.SqlServer and Persistence are both Persistence. + /// Qualification is a naming choice, never a different set of rules. + /// + public Responsibility? Classify(string projectName) + { + ArgumentNullException.ThrowIfNull(projectName); + + // A documented exception maps an established project onto the responsibility it actually has, + // so the dependency rules still apply in full - only the name is excused. + var waiver = WaiverFor(projectName); + if (waiver is not null && !string.IsNullOrEmpty(waiver.MapsTo)) + { + return Responsibilities.FirstOrDefault(responsibility => + string.Equals(responsibility.Name, waiver.MapsTo, StringComparison.OrdinalIgnoreCase) + ); + } + + var segment = projectName.Split('.')[0]; + + return Responsibilities.FirstOrDefault(responsibility => + string.Equals(responsibility.Name, segment, StringComparison.OrdinalIgnoreCase) + || responsibility.Aliases.Any(alias => string.Equals(alias, segment, StringComparison.OrdinalIgnoreCase)) + ); + } +} + +/// A single architectural responsibility and the dependencies it permits. +public sealed class Responsibility +{ + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; + + [JsonPropertyName("aliases")] + public IReadOnlyList Aliases { get; init; } = []; + + [JsonPropertyName("category")] + public string Category { get; init; } = string.Empty; + + [JsonPropertyName("purpose")] + public string Purpose { get; init; } = string.Empty; + + [JsonPropertyName("mayDependOn")] + public IReadOnlyList MayDependOn { get; init; } = []; + + [JsonPropertyName("mustNotDependOn")] + public IReadOnlyList MustNotDependOn { get; init; } = []; + + [JsonPropertyName("forbiddenPackagePrefixes")] + public IReadOnlyList ForbiddenPackagePrefixes { get; init; } = []; + + [JsonPropertyName("siblingIndependence")] + public bool SiblingIndependence { get; init; } + + [JsonPropertyName("separateProjectWhen")] + public string SeparateProjectWhen { get; init; } = string.Empty; + + [JsonPropertyName("mayRemainCombinedWhen")] + public string MayRemainCombinedWhen { get; init; } = string.Empty; +} + +/// Project names that describe a category rather than a responsibility. +public sealed class DiscouragedNames +{ + [JsonPropertyName("names")] + public IReadOnlyList Names { get; init; } = []; + + [JsonPropertyName("reason")] + public string Reason { get; init; } = string.Empty; +} + +/// A deliberate, reviewed departure from the policy for one project. +/// +/// An exception is how a rule stays enforceable in a repository that predates it. Suppressing a rule +/// silently would make the policy a lie; recording it here keeps the departure reviewable and keeps +/// every other rule in force for that project. +/// +public sealed class PolicyWaiver +{ + [JsonPropertyName("project")] + public string Project { get; init; } = string.Empty; + + [JsonPropertyName("mapsTo")] + public string MapsTo { get; init; } = string.Empty; + + [JsonPropertyName("rules")] + public IReadOnlyList Rules { get; init; } = []; + + [JsonPropertyName("reason")] + public string Reason { get; init; } = string.Empty; + + [JsonPropertyName("reviewedOn")] + public string ReviewedOn { get; init; } = string.Empty; + + /// Reports whether this exception excuses a named rule. + /// The rule identifier. + /// when the rule is excused. + public bool Excuses(string rule) => Rules.Contains(rule, StringComparer.OrdinalIgnoreCase); +} + +/// Repository-wide rules that are not expressed per responsibility. +public sealed class PolicyRules +{ + [JsonPropertyName("noCycles")] + public bool NoCycles { get; init; } + + [JsonPropertyName("noProductionToTestReferences")] + public bool NoProductionToTestReferences { get; init; } + + [JsonPropertyName("implementationsMustNotReferenceComposition")] + public bool ImplementationsMustNotReferenceComposition { get; init; } + + [JsonPropertyName("siblingImplementationsIndependent")] + public bool SiblingImplementationsIndependent { get; init; } +} diff --git a/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.cs b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.cs new file mode 100644 index 0000000..c728ab4 --- /dev/null +++ b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.cs @@ -0,0 +1,379 @@ +namespace Purview.Architecture.Testing; + +/// +/// Evaluates a repository's project graph against an architecture policy. +/// +/// +/// Deliberately free of MSBuild and test-framework types: the rules are the part worth testing +/// directly, and coupling them to a test base class would make them reachable only through one. +/// is a thin wrapper over this. +/// +public sealed class ArchitecturePolicyEvaluator +{ + readonly ArchitecturePolicy _policy; + readonly IReadOnlyDictionary _projects; + + /// Creates an evaluator over an explicit policy and graph. + /// The policy to apply. + /// The projects to evaluate, keyed by name. + public ArchitecturePolicyEvaluator(ArchitecturePolicy policy, IReadOnlyDictionary projects) + { + ArgumentNullException.ThrowIfNull(policy); + ArgumentNullException.ThrowIfNull(projects); + + _policy = policy; + _projects = projects; + } + + /// Creates an evaluator for a repository on disk. + /// The repository root, or to probe upward for one. + /// The evaluator. + public static ArchitecturePolicyEvaluator ForRepository(string? repositoryRoot = null) + { + var root = repositoryRoot ?? ArchitecturePolicy.RepositoryRoot(); + + var policy = ArchitecturePolicy.Load(root); + + return new ArchitecturePolicyEvaluator(policy, ProjectGraph.Load(root, policy.SourceRoots)); + } + + /// The policy being applied. + public ArchitecturePolicy Policy => _policy; + + /// The production projects, excluding test projects. + public IEnumerable ProductionProjects => _projects.Values.Where(project => !project.IsTestProject); + + /// Runs every rule. + /// Every violation found, in rule order. + public IReadOnlyList EvaluateAll() => + [ + .. GraphDiscovery(), + .. Naming(), + .. DiscouragedNames(), + .. DependencyMatrix(), + .. SiblingIndependence(), + .. CompositionBoundary(), + .. ForbiddenPackages(), + .. Cycles(), + .. TestReferences(), + .. WaiverIntegrity(), + ]; + + /// Reports when no projects were discovered at all. + /// The violations. + /// + /// Every other rule is satisfied by an empty graph, so without this a repository whose layout the + /// scan does not match gets a completely green architecture suite that checked nothing. Emptiness + /// has to be the loud failure, not the quiet success. + /// + public IReadOnlyList GraphDiscovery() => + ProductionProjects.Any() + ? [] + : + [ + new ArchitectureViolation( + ArchitectureRules.GraphDiscovery, + "(repository)", + "No production projects were discovered, so every other rule would pass without checking anything. " + + $"Looked under: {(_policy.SourceRoots.Count == 0 ? "(repository root)" : string.Join(", ", _policy.SourceRoots))}. " + + "Set 'sourceRoots' in the policy to the folders holding the projects; an empty array scans the repository root." + ), + ]; + + /// Projects whose names map to no responsibility. + /// The violations. + public IReadOnlyList Naming() => + [ + .. ProductionProjects + .Where(project => + _policy.Classify(project.Name) is null + && _policy.WaiverFor(project.Name)?.Excuses(ArchitectureRules.Naming) != true + ) + .Select(project => new ArchitectureViolation( + ArchitectureRules.Naming, + project.Name, + $"'{project.Name}' does not map to a known responsibility or alias. " + + "Rename it to '[.]', or record a waiver in the policy. " + + $"Known responsibilities: {string.Join(", ", _policy.Responsibilities.Select(responsibility => responsibility.Name))}." + )), + ]; + + /// Projects named after a category rather than a responsibility. + /// The violations. + public IReadOnlyList DiscouragedNames() => + [ + .. ProductionProjects + .Where(project => + _policy.DiscouragedProjectNames.Names.Contains( + project.Name.Split('.')[0], + StringComparer.OrdinalIgnoreCase + ) + && _policy.WaiverFor(project.Name)?.Excuses(ArchitectureRules.DiscouragedName) != true + ) + .Select(project => new ArchitectureViolation( + ArchitectureRules.DiscouragedName, + project.Name, + $"'{project.Name}': {_policy.DiscouragedProjectNames.Reason}" + )), + ]; + + /// References the dependency matrix forbids or does not permit. + /// The violations. + public IReadOnlyList DependencyMatrix() + { + List violations = []; + + foreach (var project in ProductionProjects) + { + var responsibility = _policy.Classify(project.Name); + if (responsibility is null) + continue; + + foreach (var reference in project.ProjectReferences) + { + var referenced = _policy.Classify(reference); + if (referenced is null) + continue; + + // A qualified sibling is the same box on the diagram, so sibling independence governs + // it rather than the matrix. + if (string.Equals(referenced.Name, responsibility.Name, StringComparison.OrdinalIgnoreCase)) + continue; + + if (responsibility.MustNotDependOn.Contains(referenced.Name, StringComparer.OrdinalIgnoreCase)) + { + violations.Add( + new ArchitectureViolation( + ArchitectureRules.DependencyMatrix, + project.Name, + $"'{project.Name}' ({responsibility.Name}) references '{reference}' ({referenced.Name}), " + + $"but {responsibility.Name} must not depend on {referenced.Name}. " + + $"Invert it: {referenced.Name} may depend on {responsibility.Name}, or introduce an abstraction " + + $"that {responsibility.Name} owns and {referenced.Name} implements." + ) + ); + } + else if (!responsibility.MayDependOn.Contains(referenced.Name, StringComparer.OrdinalIgnoreCase)) + { + violations.Add( + new ArchitectureViolation( + ArchitectureRules.DependencyMatrix, + project.Name, + $"'{project.Name}' ({responsibility.Name}) references '{reference}' ({referenced.Name}), " + + "which the policy neither permits nor forbids. " + + $"{responsibility.Name} may depend on: {string.Join(", ", responsibility.MayDependOn)}. " + + $"Add {referenced.Name} to mayDependOn in the policy if this is intended." + ) + ); + } + } + } + + return violations; + } + + /// Qualified implementations referencing a qualified sibling. + /// The violations. + public IReadOnlyList SiblingIndependence() + { + List violations = []; + + foreach (var project in ProductionProjects) + { + var responsibility = _policy.Classify(project.Name); + if (responsibility?.SiblingIndependence != true || !project.Name.Contains('.', StringComparison.Ordinal)) + continue; + + foreach (var reference in project.ProjectReferences) + { + // Only a qualified sibling is a peer; the unqualified project is shared ground. + var isQualifiedSibling = + reference.Contains('.', StringComparison.Ordinal) + && !string.Equals(reference, project.Name, StringComparison.OrdinalIgnoreCase) + && string.Equals( + _policy.Classify(reference)?.Name, + responsibility.Name, + StringComparison.OrdinalIgnoreCase + ); + + if (isQualifiedSibling) + { + violations.Add( + new ArchitectureViolation( + ArchitectureRules.SiblingIndependence, + project.Name, + $"'{project.Name}' references its sibling '{reference}'. Sibling {responsibility.Name} implementations " + + $"must stay independent; move the shared part into the unqualified '{responsibility.Name}' project " + + "or into an abstraction both can depend on." + ) + ); + } + } + } + + return violations; + } + + /// Non-host projects referencing Composition. + /// The violations. + public IReadOnlyList CompositionBoundary() => + [ + .. ProductionProjects + .Where(project => + !string.Equals( + _policy.Classify(project.Name)?.Name, + "Composition", + StringComparison.OrdinalIgnoreCase + ) + && !string.Equals( + _policy.Classify(project.Name)?.Category, + "host", + StringComparison.OrdinalIgnoreCase + ) + && project.ProjectReferences.Any(reference => + string.Equals( + _policy.Classify(reference)?.Name, + "Composition", + StringComparison.OrdinalIgnoreCase + ) + ) + ) + .Select(project => new ArchitectureViolation( + ArchitectureRules.CompositionBoundary, + project.Name, + $"'{project.Name}' references Composition. Composition selects implementations, so it depends on them - " + + "never the other way round, which would make the choice circular." + )), + ]; + + /// Packages matching a forbidden prefix for their responsibility. + /// The violations. + public IReadOnlyList ForbiddenPackages() + { + List violations = []; + + foreach (var project in ProductionProjects) + { + var responsibility = _policy.Classify(project.Name); + if (responsibility is null) + continue; + + foreach (var package in project.PackageReferences) + { + var forbidden = responsibility.ForbiddenPackagePrefixes.FirstOrDefault(prefix => + package.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ); + + if (forbidden is not null) + { + violations.Add( + new ArchitectureViolation( + ArchitectureRules.ForbiddenPackage, + project.Name, + $"'{project.Name}' ({responsibility.Name}) references package '{package}', which matches the forbidden " + + $"prefix '{forbidden}' for {responsibility.Name}. {responsibility.Purpose} " + + "Move the dependency to the responsibility that owns that technology." + ) + ); + } + } + } + + return violations; + } + + /// Cycles in the project dependency graph. + /// The violations. + public IReadOnlyList Cycles() => + [ + .. ProjectGraph + .FindCycles(_projects) + .Select(cycle => new ArchitectureViolation( + ArchitectureRules.Cycle, + cycle, + $"Dependency cycle: {cycle}. Break it by moving the shared part into a project both can depend on." + )), + ]; + + /// Production projects referencing a test project. + /// The violations. + public IReadOnlyList TestReferences() => + [ + .. ProductionProjects.SelectMany(project => + project + .ProjectReferences.Where(reference => + _projects.TryGetValue(reference, out var target) && target.IsTestProject + ) + .Select(reference => new ArchitectureViolation( + ArchitectureRules.TestReference, + project.Name, + $"'{project.Name}' references the test project '{reference}'. Test helpers must not ship." + )) + ), + ]; + + /// Waivers that are stale, unjustified or malformed. + /// The violations. + /// + /// A waiver nobody revisits is how a policy quietly stops being true, so the waivers are + /// themselves subject to rules. + /// + public IReadOnlyList WaiverIntegrity() + { + List violations = []; + + foreach (var waiver in _policy.Waivers) + { + if (!_projects.ContainsKey(waiver.Project)) + { + violations.Add( + new ArchitectureViolation( + ArchitectureRules.WaiverIntegrity, + waiver.Project, + $"The waiver for '{waiver.Project}' names a project that does not exist. Remove it - a stale waiver hides the next one." + ) + ); + } + + if (waiver.Rules.Count == 0) + { + violations.Add( + new ArchitectureViolation( + ArchitectureRules.WaiverIntegrity, + waiver.Project, + $"The waiver for '{waiver.Project}' excuses no named rule, so it does nothing." + ) + ); + } + + if (waiver.MapsTo.Length > 0 && _policy.Classify(waiver.Project) is null) + { + violations.Add( + new ArchitectureViolation( + ArchitectureRules.WaiverIntegrity, + waiver.Project, + $"The waiver for '{waiver.Project}' maps to unknown responsibility '{waiver.MapsTo}', so no dependency rule applies to it." + ) + ); + } + } + + return violations; + } + + /// Renders violations as a numbered list for a failure message. + /// The violations to render. + /// The rendered report. + public static string Report(IReadOnlyList violations) + { + ArgumentNullException.ThrowIfNull(violations); + + return violations.Count == 0 + ? "no violations" + : Environment.NewLine + + string.Join( + Environment.NewLine, + violations.Select((violation, index) => $" {index + 1}. {violation.Message}") + ); + } +} diff --git a/src/src/ArchitectureTesting/ArchitectureTesting.csproj b/src/src/ArchitectureTesting/ArchitectureTesting.csproj new file mode 100644 index 0000000..bf5e66a --- /dev/null +++ b/src/src/ArchitectureTesting/ArchitectureTesting.csproj @@ -0,0 +1,60 @@ + + + Purview.Architecture.Testing + Purview Architecture Testing + Evaluates a repository's project graph against a declared architecture policy: dependency direction, sibling independence, composition boundaries, forbidden framework packages, cycles and project naming. Ships the policy JSON Schema and a TUnit base class, so a consuming repository adds a policy file and one derived test class. + architecture;architecture-tests;dependency-rules;tunit;purview;dotnet + true + + + net10.0 + + + Purview.Architecture.Testing + Purview.Architecture.Testing + Purview.Architecture + + false + + + + + + + + + + + + + + + diff --git a/src/src/ArchitectureTesting/ArchitectureViolation.cs b/src/src/ArchitectureTesting/ArchitectureViolation.cs new file mode 100644 index 0000000..b5ba767 --- /dev/null +++ b/src/src/ArchitectureTesting/ArchitectureViolation.cs @@ -0,0 +1,55 @@ +namespace Purview.Architecture.Testing; + +/// A single breach of the architecture policy. +/// The rule identifier, so a caller can filter or waive by rule. +/// The offending project. +/// +/// What broke and what to do about it. A failure that only states the fact - "expected empty" - +/// costs the reader the whole investigation, so every message names the offending reference, the +/// direction the dependency should run, and the likely fix. +/// +public sealed record ArchitectureViolation(string Rule, string Project, string Message) +{ + /// + public override string ToString() => Message; +} + +/// The rule identifiers the evaluator reports and a waiver may excuse. +public static class ArchitectureRules +{ + /// A project name that maps to no responsibility or alias. + public const string Naming = "naming"; + + /// A project named after a category rather than a responsibility. + public const string DiscouragedName = "discouragedName"; + + /// A reference the dependency matrix forbids or does not permit. + public const string DependencyMatrix = "dependencyMatrix"; + + /// A qualified implementation referencing a qualified sibling. + public const string SiblingIndependence = "siblingIndependence"; + + /// A non-host project referencing Composition. + public const string CompositionBoundary = "compositionBoundary"; + + /// A package whose identifier matches a forbidden prefix for the responsibility. + public const string ForbiddenPackage = "forbiddenPackage"; + + /// A cycle in the project dependency graph. + public const string Cycle = "cycle"; + + /// Production code referencing a test project. + public const string TestReference = "testReference"; + + /// A waiver that is stale, unjustified or malformed. + public const string WaiverIntegrity = "waiverIntegrity"; + + /// + /// No projects were discovered, so every other rule would pass vacuously. + /// + /// + /// The most dangerous result this package can produce is a green run that checked nothing. An + /// empty graph satisfies every rule by construction, so emptiness is itself a violation. + /// + public const string GraphDiscovery = "graphDiscovery"; +} diff --git a/src/src/ArchitectureTesting/PolicySchemaValidator.cs b/src/src/ArchitectureTesting/PolicySchemaValidator.cs new file mode 100644 index 0000000..d62ad29 --- /dev/null +++ b/src/src/ArchitectureTesting/PolicySchemaValidator.cs @@ -0,0 +1,110 @@ +using System.Text.Json; +using Json.Schema; + +namespace Purview.Architecture.Testing; + +/// +/// Validates a policy document against the schema embedded in this package. +/// +/// +/// The schema is embedded rather than fetched, so validation needs no network and cannot drift from +/// the reader in this same package. The identical file is published at for +/// editors, which is what a policy's $schema points at. +/// +public static class PolicySchemaValidator +{ + const string SchemaResourceName = "Purview.Architecture.Testing.PolicySchema.json"; + + /// The published identifier a policy file's $schema should reference. + public const string SchemaId = + "https://raw.githubusercontent.com/purview-dev/build-sdk/main/architecture-policy.schema.json"; + + /// + /// The parsed schema. + /// + /// + /// JsonSchema.FromText registers a schema globally by its $id, and a second + /// registration of the same identifier throws. Parsing once is required, not just an optimisation. + /// + static readonly Lazy Schema = new(LoadSchema); + + /// Validates a policy document. + /// The path to the policy file. + /// The failures, each naming the instance location that broke. Empty when valid. + public static IReadOnlyList Validate(string policyPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(policyPath); + + if (!File.Exists(policyPath)) + return [$"The architecture policy was not found at '{policyPath}'."]; + + using var document = JsonDocument.Parse( + File.ReadAllText(policyPath), + // A policy may carry comments and trailing commas; an editor tolerates both, so validation + // must too, or the file is valid in one tool and broken in another. + new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true } + ); + + var results = Schema.Value.Evaluate( + document.RootElement, + // Hierarchical carries the failing keyword and instance location; the default output says + // only valid or invalid, which is useless for a file this size. + new EvaluationOptions { OutputFormat = OutputFormat.Hierarchical, RequireFormatValidation = true } + ); + + return results.IsValid + ? [] + : + [ + .. Leaves(results) + .SelectMany(leaf => leaf.Errors?.Select(error => $"{Location(leaf)}: {error.Value}") ?? []), + ]; + } + + /// Reads the embedded schema. + /// The schema. + static JsonSchema LoadSchema() + { + using var stream = + typeof(PolicySchemaValidator).Assembly.GetManifestResourceStream(SchemaResourceName) + ?? throw new InvalidOperationException($"The embedded policy schema '{SchemaResourceName}' was not found."); + using StreamReader reader = new(stream); + + return JsonSchema.FromText(reader.ReadToEnd()); + } + + /// + /// The failing results with no failing children, so a container failure reports the key that + /// caused it rather than a generic message about the object. + /// + /// The results to walk. + /// The failing leaves. + static IEnumerable Leaves(EvaluationResults results) + { + var children = (results.Details ?? []).Where(detail => !detail.IsValid).ToList(); + + if (children.Count == 0) + { + if (!results.IsValid) + yield return results; + + yield break; + } + + foreach (var child in children) + { + foreach (var leaf in Leaves(child)) + yield return leaf; + } + } + + /// Renders an instance location as a readable path. + /// The failing result. + /// The location. + static string Location(EvaluationResults result) + { + var pointer = result.InstanceLocation.ToString(); + + return string.IsNullOrEmpty(pointer) ? "(root)" : pointer.TrimStart('/').Replace('/', '.'); + } +} diff --git a/src/src/ArchitectureTesting/ProjectGraph.cs b/src/src/ArchitectureTesting/ProjectGraph.cs new file mode 100644 index 0000000..acf46ac --- /dev/null +++ b/src/src/ArchitectureTesting/ProjectGraph.cs @@ -0,0 +1,181 @@ +using System.Xml.Linq; + +namespace Purview.Architecture.Testing; + +/// A project in the solution, with the references it declares. +/// The project name, without extension. +/// The absolute path to the project file. +/// The names of the projects it references directly. +/// The package identifiers it references directly. +/// Whether the project is a test project. +public sealed record ProjectNode( + string Name, + string Path, + IReadOnlyList ProjectReferences, + IReadOnlyList PackageReferences, + bool IsTestProject +); + +/// +/// Reads the solution's projects and their declared references straight from the project files. +/// +/// +/// Project files are read rather than compiled assemblies because a declared +/// ProjectReference is the thing a reviewer adds and the thing the policy governs. Compiled +/// assembly references are checked separately - they catch a dependency that arrived indirectly, +/// which the project file does not show. +/// +public static class ProjectGraph +{ + /// Loads every project beneath the repository's default source folder. + /// The repository root. + /// The projects, keyed by name. + public static IReadOnlyDictionary Load(string repositoryRoot) => Load(repositoryRoot, ["src"]); + + /// Loads every project beneath the given source roots. + /// The repository root. + /// + /// Repo-relative folders to scan. An empty collection scans the repository root, which suits a + /// flat layout. + /// + /// The projects, keyed by name. + /// + /// A source root that does not exist is skipped rather than throwing, because a repository may + /// legitimately configure roots it does not all use. The caller must notice an *empty* result: + /// an empty graph satisfies every other rule, so it would otherwise pass silently. That is what + /// is for. + /// + public static IReadOnlyDictionary Load( + string repositoryRoot, + IReadOnlyList sourceRoots + ) + { + ArgumentNullException.ThrowIfNull(sourceRoots); + + Dictionary projects = new(StringComparer.OrdinalIgnoreCase); + + var roots = + sourceRoots.Count == 0 ? [repositoryRoot] : sourceRoots.Select(root => Path.Combine(repositoryRoot, root)); + + foreach (var sourceRoot in roots) + { + if (Directory.Exists(sourceRoot)) + Scan(sourceRoot, projects); + } + + return projects; + } + + /// Scans one source root for projects. + /// The folder to scan. + /// The accumulating project map. + static void Scan(string sourceRoot, Dictionary projects) + { + foreach (var projectPath in Directory.EnumerateFiles(sourceRoot, "*.csproj", SearchOption.AllDirectories)) + { + // bin/ and obj/ can hold copies of project files during a build; they are not the source. + if ( + projectPath.Contains( + $"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", + StringComparison.Ordinal + ) + || projectPath.Contains( + $"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", + StringComparison.Ordinal + ) + ) + { + continue; + } + + var node = Read(projectPath); + projects[node.Name] = node; + } + } + + /// Reads a single project file. + /// The project file path. + /// The project node. + static ProjectNode Read(string projectPath) + { + var document = XDocument.Load(projectPath); + var name = Path.GetFileNameWithoutExtension(projectPath); + + var projectReferences = document + .Descendants("ProjectReference") + .Select(element => (string?)element.Attribute("Include")) + .Where(include => !string.IsNullOrWhiteSpace(include)) + .Select(include => Path.GetFileNameWithoutExtension(include!.Replace('\\', Path.DirectorySeparatorChar))) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var packageReferences = document + .Descendants("PackageReference") + .Select(element => (string?)element.Attribute("Include") ?? (string?)element.Attribute("Update")) + .Where(include => !string.IsNullOrWhiteSpace(include)) + .Select(include => include!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + // The SDK derives IsTestProject from the folder, so the path is the reliable signal here. + var isTestProject = + name.EndsWith("Tests", StringComparison.OrdinalIgnoreCase) + || projectPath.Contains( + $"{Path.DirectorySeparatorChar}tests{Path.DirectorySeparatorChar}", + StringComparison.OrdinalIgnoreCase + ); + + return new ProjectNode(name, projectPath, projectReferences, packageReferences, isTestProject); + } + + /// Finds every dependency cycle in the project graph. + /// The projects to inspect. + /// Each cycle, rendered as the path that closes it. + /// + /// MSBuild itself rejects a cycle, so this is a guard that reports the cycle readably rather than + /// as a build error naming two projects. It also covers a graph assembled in a test fixture. + /// + public static IReadOnlyList FindCycles(IReadOnlyDictionary projects) + { + ArgumentNullException.ThrowIfNull(projects); + + List cycles = new(); + Dictionary state = new(); + + foreach (var project in projects.Keys) + Visit(project, []); + + return cycles; + + void Visit(string current, List path) + { + // 1 = on the current path, 2 = fully explored and clean. + if (state.TryGetValue(current, out var visited)) + { + if (visited == 1) + { + var start = path.FindIndex(entry => + string.Equals(entry, current, StringComparison.OrdinalIgnoreCase) + ); + if (start >= 0) + cycles.Add(string.Join(" -> ", path[start..].Append(current))); + } + + return; + } + + state[current] = 1; + path.Add(current); + + if (projects.TryGetValue(current, out var node)) + { + foreach (var reference in node.ProjectReferences) + Visit(reference, path); + } + + path.RemoveAt(path.Count - 1); + state[current] = 2; + } + } +} diff --git a/src/src/ArchitectureTesting/PurviewArchitectureTests.cs b/src/src/ArchitectureTesting/PurviewArchitectureTests.cs new file mode 100644 index 0000000..89137ee --- /dev/null +++ b/src/src/ArchitectureTesting/PurviewArchitectureTests.cs @@ -0,0 +1,120 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Purview.Architecture.Testing; + +/// +/// The standard architecture rule set, as TUnit tests. +/// +/// +/// A consuming repository adds a policy file and one derived class: +/// +/// [InheritsTests] +/// public sealed class ArchitectureTests : PurviewArchitectureTests; +/// +/// +/// The [InheritsTests] attribute is required. TUnit does not walk base types when it +/// discovers tests, so without it the class compiles, the run is green, and nothing is enforced - +/// the worst failure this package could have. TUnit's own analyzer reports TUnit0030 when it is +/// missing; treat that as an error rather than a suggestion. +/// +/// Each rule is a separate test so a failure names the rule, and the whole set does not collapse +/// into one assertion. Override when the policy does not sit above the +/// test binary, and to opt out of a rule the repository has not adopted yet - +/// preferring a recorded waiver, which is narrower and stays visible. +/// +[SuppressMessage( + "Naming", + "CA1707:Identifiers should not contain underscores", + Justification = "These are TUnit tests that run in the consuming repository, so they follow the " + + "Subject_GivenCondition_Outcome test-naming convention rather than library member naming. The " + + "type ships in a package only because the rule set is shared; it is not an API anyone calls." +)] +public abstract class PurviewArchitectureTests +{ + ArchitecturePolicyEvaluator? _evaluator; + + /// The repository root. Defaults to probing upward for the policy file. + protected virtual string? RepositoryRoot => null; + + /// The evaluator, created once per test class instance. + protected ArchitecturePolicyEvaluator Evaluator => + _evaluator ??= ArchitecturePolicyEvaluator.ForRepository(RepositoryRoot); + + /// Reports whether a rule should be skipped for this repository. + /// The rule identifier, from . + /// to skip the rule. + /// + /// Prefer a waiver in the policy: it is per project rather than per repository, carries a reason, + /// and is itself checked. Skipping a rule wholesale removes it with no record. + /// + protected virtual bool SkipRule(string rule) => false; + + [Test] + public async Task Policy_GivenTheSchema_Validates() + { + // A policy that does not match its schema may be silently missing a rule, so this runs first. + var failures = PolicySchemaValidator.Validate( + Path.Combine(RepositoryRoot ?? ArchitecturePolicy.RepositoryRoot(), ArchitecturePolicy.RelativePath) + ); + + await Assert + .That(failures) + .IsEmpty() + .Because( + Environment.NewLine + string.Join(Environment.NewLine, failures.Select(failure => $" {failure}")) + ); + } + + [Test] + public Task Repository_GivenItsSourceRoots_YieldsProjectsToCheck() => + // Runs before the rules it protects: an empty graph satisfies all of them. + AssertRule(ArchitectureRules.GraphDiscovery, Evaluator.GraphDiscovery()); + + [Test] + public Task ProductionProjects_GivenTheirNames_MapToAResponsibility() => + AssertRule(ArchitectureRules.Naming, Evaluator.Naming()); + + [Test] + public Task ProductionProjects_GivenTheirNames_AvoidCatchAllNames() => + AssertRule(ArchitectureRules.DiscouragedName, Evaluator.DiscouragedNames()); + + [Test] + public Task ProductionProjects_GivenTheirReferences_RespectTheDependencyMatrix() => + AssertRule(ArchitectureRules.DependencyMatrix, Evaluator.DependencyMatrix()); + + [Test] + public Task SiblingImplementations_GivenTheirReferences_DoNotDependOnEachOther() => + AssertRule(ArchitectureRules.SiblingIndependence, Evaluator.SiblingIndependence()); + + [Test] + public Task Implementations_GivenTheirReferences_DoNotDependOnComposition() => + AssertRule(ArchitectureRules.CompositionBoundary, Evaluator.CompositionBoundary()); + + [Test] + public Task ProductionProjects_GivenTheirPackages_AvoidForbiddenFrameworkDependencies() => + AssertRule(ArchitectureRules.ForbiddenPackage, Evaluator.ForbiddenPackages()); + + [Test] + public Task ProjectGraph_GivenItsReferences_HasNoCycles() => + AssertRule(ArchitectureRules.Cycle, Evaluator.Cycles()); + + [Test] + public Task ProductionProjects_GivenTheirReferences_DoNotReferenceATestProject() => + AssertRule(ArchitectureRules.TestReference, Evaluator.TestReferences()); + + [Test] + public Task PolicyWaivers_GivenTheirDefinitions_AreJustifiedAndStillApply() => + AssertRule(ArchitectureRules.WaiverIntegrity, Evaluator.WaiverIntegrity()); + + /// Asserts a rule produced no violations, unless the repository skipped it. + /// The rule identifier. + /// The violations the rule produced. + /// The assertion. + async Task AssertRule(string rule, IReadOnlyList violations) + { + if (SkipRule(rule)) + return; + + await Assert.That(violations).IsEmpty().Because(ArchitecturePolicyEvaluator.Report(violations)); + } +} diff --git a/src/src/ArchitectureTesting/README.md b/src/src/ArchitectureTesting/README.md new file mode 100644 index 0000000..60ce73c --- /dev/null +++ b/src/src/ArchitectureTesting/README.md @@ -0,0 +1,93 @@ +# Purview.Architecture.Testing + +Enforces a repository's project dependency and naming policy as TUnit tests, so the rules are +defined once rather than copied into every repository's test project. + +## Install + +```bash +dotnet add package Purview.Architecture.Testing +``` + +## Adopt + +Two things. A policy at `.config/architecture-policy.json`: + +```json +{ + "$schema": "https://raw.githubusercontent.com/purview-dev/build-sdk/main/architecture-policy.schema.json", + "version": 1, + "sourceRoots": ["src"], + "responsibilities": [ + { + "name": "Core", + "aliases": ["Domain"], + "category": "domain", + "purpose": "The domain model, its invariants and its rules.", + "mayDependOn": [], + "mustNotDependOn": ["Application", "Persistence"] + } + ] +} +``` + +And one derived test class: + +```csharp +[InheritsTests] +public sealed class ArchitectureTests : PurviewArchitectureTests; +``` + +> **`[InheritsTests]` is required.** TUnit does not walk base types during discovery, so without it +> the class compiles, the run is green and nothing is enforced. TUnit's analyzer reports `TUnit0030` +> — treat it as an error. + +## The rules + +| Rule | Catches | +| -- | -- | +| `graphDiscovery` | An empty graph, which satisfies every other rule | +| `naming` | A project name matching no responsibility or alias | +| `discouragedName` | A project named after a category, so nothing can be kept out of it | +| `dependencyMatrix` | A reference the matrix forbids or does not permit | +| `siblingIndependence` | A qualified implementation referencing a qualified sibling | +| `compositionBoundary` | A non-host project referencing Composition | +| `forbiddenPackage` | A package matching a forbidden prefix for that responsibility | +| `cycle` | A dependency cycle, reported readably | +| `testReference` | Production code referencing a test project | +| `waiverIntegrity` | A waiver that is stale, unjustified or malformed | + +The policy is validated against its schema first, because a policy that does not match its schema +may be silently missing a rule. + +## Waivers over skips + +```json +"waivers": [ + { + "project": "Legacy.Utilities", + "mapsTo": "Shared", + "rules": ["naming"], + "reason": "Predates the catalogue; renaming breaks a published package id.", + "reviewedOn": "2026-01-14" + } +] +``` + +`SkipRule` removes a rule for the whole repository with no record. A waiver is per project, carries +a reason and a review date, keeps every other rule in force, and is itself checked. `mapsTo` maps an +established project onto the responsibility it actually has, so only the *name* is excused. + +## Design + +Three layers, deliberately separated: `ArchitecturePolicy`/`ProjectGraph` read the policy and the +`.csproj` graph with no MSBuild or test-framework types; `ArchitecturePolicyEvaluator` holds the +rules and takes an in-memory graph, so a rule is testable as a three-project fixture; +`PurviewArchitectureTests` is the TUnit wrapper with one `[Test]` per rule. + +Project files are read rather than compiled assemblies, because a declared `ProjectReference` is +what a reviewer adds and what the policy governs. + +--- + +Part of [Purview.BuildSdk](https://github.com/purview-dev/build-sdk) · [purview.dev](https://purview.dev/projects/build-sdk/) diff --git a/src/src/BuildSdk/BuildSdk.csproj b/src/src/BuildSdk/BuildSdk.csproj index 32ecf92..23b1ce4 100644 --- a/src/src/BuildSdk/BuildSdk.csproj +++ b/src/src/BuildSdk/BuildSdk.csproj @@ -13,6 +13,7 @@ false false true + true true @@ -29,6 +30,10 @@ true false + @@ -58,12 +63,6 @@ Visible="true" /> - diff --git a/src/src/BuildSdk/Sdk/.agents/agents/sdk-consumer-setup.md b/src/src/BuildSdk/Sdk/.agents/agents/sdk-consumer-setup.md deleted file mode 100644 index c3cc84f..0000000 --- a/src/src/BuildSdk/Sdk/.agents/agents/sdk-consumer-setup.md +++ /dev/null @@ -1,38 +0,0 @@ -# sdk-consumer-setup (generic agent spec) - -## Goal - -Help a consuming repository adopt or troubleshoot `Purview.BuildSdk` correctly, without breaking existing build behaviour. - -## Workflow - -1. Confirm the SDK is imported in `Directory.Build.props`/`Directory.Build.targets` via - `` and the matching `Sdk.targets` import. -2. Check pre-import bootstrap properties are set **before** the `Sdk.props` import when they must affect - evaluation: `NamespacePrefix`, `UsePackageJsonVersion`, `RootPackageJson`. -3. If version resolution looks wrong, verify `package.json` discovery: explicit `RootPackageJson`, then CI - variables, `.git` root, or a nearby `package.json`. `UsePackageJsonVersion=Strict` fails fast instead of - silently skipping resolution. -4. If the bundled `.agents/**` content isn't appearing in the repo root, check `EnableAgentFolderInPackage` - (default `true`) and `AgentPackDestinationFolder` (default `.agents`) - the copy runs before build via - `EnsureAgentFolderInPackageTarget`. -5. For test-framework or project-shape questions, confirm the project follows repo naming and placement - conventions the SDK expects, rather than introducing bespoke structure. -6. For naming, layout, and test readability questions, start from the engineering principles: naming and - placement are configuration, short project names are preferred, the detected test type becomes the baseline - category, and subject-based tests should be named for the subject they own. -7. Re-run `dotnet build` (or the repo's canonical build command) after each configuration change to confirm - the fix. - -## Constraints - -- Prefer minimal, targeted property changes over broad `Directory.Build.props` rewrites. -- Do not disable `PurviewAutoSdkPack` or `EnableAgentFolderInPackage` unless the consumer explicitly asks to - opt out. -- Do not duplicate SDK-managed properties in individual project files unless the scenario is intentionally - project-specific. - -## Related skill - -See `../skills/sdk-configuration-reference/SKILL.md` for the full property reference. -See `../skills/sdk-engineering-principles/SKILL.md` for the higher-level repository conventions. diff --git a/src/src/BuildSdk/Sdk/.agents/agents/sdk-repository-rationaliser.md b/src/src/BuildSdk/Sdk/.agents/agents/sdk-repository-rationaliser.md deleted file mode 100644 index 5fb020c..0000000 --- a/src/src/BuildSdk/Sdk/.agents/agents/sdk-repository-rationaliser.md +++ /dev/null @@ -1,39 +0,0 @@ -# sdk-repository-rationaliser (generic agent spec) - -## Goal - -Help a repository move toward `Purview.BuildSdk` conventions without unnecessary churn. - -## Workflow - -1. Start by reading the repo's `Directory.Build.props`, `Directory.Build.targets`, solution entry point, - and current project layout. -2. Identify the current `NamespacePrefix`, project naming scheme, and test project suffixes in use. -3. Compare the repo's structure against the engineering principles: - - short project names - - source/test split where practical - - exact shared/shared-testing names when SDK behavior is expected - - readable, scalable test naming -4. Separate findings into three groups: - - already aligned - - misaligned but harmless - - misaligned and blocking SDK automatic behavior -5. Prefer the smallest sequence of changes that improves predictability without forcing broad renames. -6. When recommending test changes, preserve readable behavior-oriented suites while tightening subject-based - suites toward `{SubjectName}Tests` and `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}`. -7. Confirm whether specialized dependencies such as `TUnit.Aspire` or `Purview.Containers` are genuinely - needed rather than treating them as universal defaults. Where a repository references `Testcontainers` - directly, check whether a `Purview.Containers` typed module now covers it. - -## Constraints - -- Do not assume every older repo should be renamed wholesale. -- Preserve meaningful established structure unless it interferes with SDK inference. -- Prefer explaining the effect on `RootNamespace`, `AssemblyName`, `PackageId`, `TestingType`, and - `TargetProjectName` instead of arguing from taste. - -## Related skills - -- `../skills/sdk-engineering-principles/SKILL.md` -- `../skills/project-placement-defaults/SKILL.md` -- `../skills/sdk-project-behavior-and-detection/SKILL.md` diff --git a/src/src/BuildSdk/Sdk/.agents/prompts/sdk-diagnose-agent-folder-copy.md b/src/src/BuildSdk/Sdk/.agents/prompts/sdk-diagnose-agent-folder-copy.md deleted file mode 100644 index e234df9..0000000 --- a/src/src/BuildSdk/Sdk/.agents/prompts/sdk-diagnose-agent-folder-copy.md +++ /dev/null @@ -1,37 +0,0 @@ -# sdk-diagnose-agent-folder-copy (generic prompt spec) - -Diagnose why the bundled `.agents/**` folder from `Purview.BuildSdk` did not appear at the expected -destination in a consuming repository. - -## Required behaviour - -1. Confirm the NuGet package actually contains `.agents/**` content (inspect the `.nupkg` if available). -2. Confirm the consuming project is packable/buildable and imports the SDK via - `Sdk.props`/`Sdk.targets`, since the copy runs in `EnsureAgentFolderInPackageTarget` before build. -3. Check `EnableAgentFolderInPackage` is not set to `false` anywhere in the build (project file, - `Directory.Build.props`, or command-line `-p:` overrides). -4. Confirm the destination folder: default is `.agents` at the repo root, overridable per-build with - `-p:AgentPackDestinationFolder=`, and the source defaults to the package-level `.agents` - folder (override with `PurviewAgentFolderSourcePath`). -5. Verify repo-root discovery succeeded: explicit `RepoRoot`, then a nearby `AGENTS.md`, then source-control - root metadata. -6. If the destination looks stale or incomplete, inspect the change-detection manifest - (`/.purview/agent-sync.cache`). It lists the files the SDK believes it already mirrored; a - matching entry with a present destination file means the sync was skipped as up to date. Delete the - manifest (or the affected destination file) to force a fresh copy. -7. Retry notices are demoted to low-importance messages, so a healthy build shows no `MSB3026` warnings. - A copy that still fails after every retry is reported as an error naming the source, destination and - OS error - search the build log for `The Purview SDK could not copy`. Temporarily set - `PurviewAgentFolderCopyRetries` / `PurviewAgentFolderCopyRetryDelayMilliseconds` to retry longer, and - `PurviewSuppressCopyRetryWarnings=false` to see every retry attempt. -8. Re-run the build and confirm the destination folder now contains the copied files (including the - generated `.gitignore` for skill/prompt/agent subfolders). - -## Suggested output - -- A short root-cause explanation (missing import, disabled flag, wrong destination override, repo-root - discovery miss, or a destination held open by another process). -- The exact command used to reproduce/verify the fix (for example - `dotnet build -p:AgentPackDestinationFolder=`). -- Confirmation that the expected files exist at the resolved destination path, plus whether the - manifest skipped the sync (in which case the content was already up to date). diff --git a/src/src/BuildSdk/Sdk/.agents/prompts/sdk-review-repository-shape.md b/src/src/BuildSdk/Sdk/.agents/prompts/sdk-review-repository-shape.md deleted file mode 100644 index eb089c9..0000000 --- a/src/src/BuildSdk/Sdk/.agents/prompts/sdk-review-repository-shape.md +++ /dev/null @@ -1,33 +0,0 @@ -# sdk-review-repository-shape (generic prompt spec) - -Review a repository that uses `Purview.BuildSdk` for naming, placement, and test-structure alignment. - -## Required behaviour - -1. Inspect the repository's `Directory.Build.props`, `Directory.Build.targets`, solution entry point, and - project layout before making assumptions. -2. Identify whether the repository follows the SDK-friendly structure: - - source projects under `src/` - - test projects under `tests/` - - `.csproj` filenames matching directory names - - short project names with `NamespacePrefix` carrying the repo identity -3. Check whether test project names use recognised `*Tests` suffixes and whether shared/shared-testing - projects use exact SDK-recognised names. -4. Explain the consequences of deviations in terms of automatic `RootNamespace`, `AssemblyName`, - `PackageId`, `TargetProjectName`, and automatic project references. -5. Review test readability conventions: - - subject-based `{SubjectName}Tests` - - subject-based `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}` method names - - non-subject-based suites named clearly for their broader role - - appropriate use of TUnit categories and display names -6. Distinguish between: - - acceptable existing variance worth preserving - - structural debt that blocks the SDK's automatic behavior - - incremental rationalisation opportunities - -## Suggested output - -- A concise summary of whether the repo broadly fits the SDK conventions. -- A list of concrete mismatches, ordered by impact. -- A list of low-risk rationalisation steps for naming, placement, identity, or test readability. -- Explicit note of which behaviors are already automatic defaults and which require manual configuration. diff --git a/src/src/BuildSdk/Sdk/.agents/skills/project-placement-defaults/SKILL.md b/src/src/BuildSdk/Sdk/.agents/skills/project-placement-defaults/SKILL.md deleted file mode 100644 index bbf7c4d..0000000 --- a/src/src/BuildSdk/Sdk/.agents/skills/project-placement-defaults/SKILL.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -name: project-placement-defaults -description: "Use when creating, moving, or splitting projects in a repository that uses Purview.BuildSdk, especially for src/tests placement, test suffix naming, namespace alignment, and automatic project-reference behavior." ---- - -# Project placement defaults for Purview.BuildSdk - -Use this skill whenever a task asks to add, move, split, or create a project in a repository that uses `Purview.BuildSdk` and you need placement, naming, and reference decisions to remain consistent with the SDK's automatic conventions. - -## Core principle - -Preserve the host repository's existing layout first; only introduce new structure when no established pattern exists. In repositories that use `Purview.BuildSdk`, prefer layouts that let the SDK's naming and auto-reference rules work without extra overrides. - -Treat naming and placement as configuration, not decoration. - -## Placement heuristics - -Use the repository's current structure as the source of truth, with these Purview-friendly defaults: - -1. Prefer source projects under `src/`. -2. Prefer test projects under `tests/`. -3. Place new projects beside similar projects (same language, layer, and test type). -4. Keep one test type per project by default. -5. Keep shared helper projects in explicit shared/shared-testing locations when those concepts exist. - -When a repo has no clear structure, use these conservative defaults because they align well with the SDK's automatic project-reference search paths: - -- Source/library projects under `src/` -- Test projects under `tests/` -- Integration/end-to-end tests in explicit sibling projects/folders such as `tests/Api.IntegrationTests/` or `tests/Api.E2ETests/` - -## Naming rules - -The SDK relies heavily on project names. - -- Keep the `.csproj` filename equal to its containing directory name unless `DisableProjectFileNamingConventionCheck=true` is explicitly used. -- Use the common conventional test suffixes by default: `.UnitTests`, `.IntegrationTests`, `.E2ETests`, `.FunctionalTests`, `.ContractTests`. -- Use other supported `*Tests` suffixes only when the test type itself carries important operational meaning. -- Keep shared helper projects on the SDK's exact recognized names when you want shared behavior: - - Shared projects: `Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, `SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers` - - Shared testing projects: `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, `SharedTestingUtilities`, `SharedTestingUtils`, `SharedTestingLibrary`, `SharedTestingLib`, `SharedTestingHelpers` -- Do not invent near-miss names if you expect the SDK to classify the project automatically. - -## Test-type boundaries - -Separate tests by behavior and dependency scope: - -- **Unit tests**: isolate logic with minimal external dependencies. -- **Integration tests**: verify behavior across component boundaries (I/O, framework integration, build/evaluation behavior). -- **End-to-end/system tests**: verify full workflow behavior across the assembled system. - -If specialized test categories exist (for example, analyzer diagnostics vs code-fix integration), keep category-specific tests in distinct projects/folders. - -The detected test type also becomes the baseline test category. Additional categories remain available and -should be added when they improve discoverability. - -Test detection is a pattern (`^.*\.([\w|_]\w*)Tests?$`), not a lookup against a fixed list of suffixes, so any word before `Test`/`Tests` is detected and becomes the category — `Unit`, `Integration`, `E2E` and `Contract` are simply the common ones. The dot is required: a project named plainly `UnitTests`, with no target in front of it, is not detected as a test project at all. Prefer a suffix the SDK ships (it is the set that drives `InternalsVisibleTo` generation) and name the project `.Tests`. - -## Naming and namespace defaults - -Align identities with existing repository conventions: - -- Project names should follow prevailing patterns in sibling projects. -- Test project names should clearly indicate scope/type with recognized test suffixes. -- `NamespacePrefix` should remain the root identity source for the repo. -- `RootNamespace` usually flows from the logical project identity generated by the SDK; avoid custom namespace overrides unless required. -- `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace` — or to the full logical project name when suffix-stripping removed a segment (e.g. `Shared`, `ServiceDefaults`) — so a project's package/assembly identity follows its namespace and stays distinct, unless the repo explicitly overrides `AssemblyName`/`PackageId`/`RootNamespace` or opts out via `EnableAssemblyNameGeneration=false`. -- When moving files between projects, update namespaces so they match the destination project's conventions. - -Do not invent a new naming scheme when an existing one is already in use. - -## Project defaults - -When creating a new project: - -1. Match the SDK/project style used by sibling projects. -2. Reuse central dependency/version management if present. -3. Add only dependencies required for the project's scope. -4. Add the project to the repository solution/workspace entry point. -5. Keep configuration consistent with neighboring projects (target frameworks, nullable, analyzers, warnings). - -When working in a Purview-based repo, also assume: - -- `TargetFramework` defaults to `net10.0` if not otherwise set, or `netstandard2.0` when the project explicitly declares `IsRoslynComponent=true`. -- Test projects receive framework packages and coverage defaults from the SDK. -- Standard test projects receive `TUnit`, `TUnit.Mocks`, `Bogus`, and Microsoft.Testing.Platform wiring by default. -- Non-test projects receive SourceLink and telemetry defaults unless explicitly opted out. - -## Test readability defaults - -When organizing tests: - -- Prefer one subject-focused `{SubjectName}Tests` class per owned subject. -- Use `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}` for subject-based test methods. -- Treat constructors, properties, operators, conversions, and validation hooks as valid subjects. -- Allow broader suite names for non-subject-based tests such as build, generator, workflow, package, or - full-system suites. -- Use TUnit display names and categories to keep large suites readable. - -## Move/split workflow checklist - -When splitting or relocating tests/projects: - -1. Create destination project/folder using established layout patterns. -2. Move files physically. -3. Update namespaces/imports/references for the destination. -4. Verify the destination project name still produces the intended `TestingType`, `TargetProjectName`, and `RootNamespace`. -5. Remove stale dependencies from the source project. -6. Update solution/workspace membership and project references. -6. Run build and relevant tests. - -## Automatic project-reference behavior to preserve - -The SDK automatically searches for project references based on naming and placement. - -- Test projects probe for their target project in these relative locations: - - `../$(TargetProjectName)/$(TargetProjectName).csproj` - - `../../$(TargetProjectName)/$(TargetProjectName).csproj` - - `../src/$(TargetProjectName)/$(TargetProjectName).csproj` - - `../../src/$(TargetProjectName)/$(TargetProjectName).csproj` -- Non-test projects automatically look for sibling shared projects via `../Shared*/Shared*.csproj`. -- Test projects automatically look for sibling shared-testing projects via `../SharedTesting*/SharedTesting*.csproj`. - -If you move projects away from these conventions, be prepared to add explicit project references. - -## Guardrails - -- Prefer minimal, targeted diffs. -- Avoid cross-cutting renames unrelated to the move/split intent. -- Keep test intent unchanged while relocating. -- If structure is ambiguous, infer from nearest sibling projects and document the assumption in the change summary. -- When in doubt, preserve compatibility with the SDK's automatic naming, namespace, and project-reference behavior. diff --git a/src/src/BuildSdk/Sdk/.agents/skills/sdk-configuration-reference/SKILL.md b/src/src/BuildSdk/Sdk/.agents/skills/sdk-configuration-reference/SKILL.md deleted file mode 100644 index 28dc937..0000000 --- a/src/src/BuildSdk/Sdk/.agents/skills/sdk-configuration-reference/SKILL.md +++ /dev/null @@ -1,205 +0,0 @@ ---- -name: sdk-configuration-reference -description: "Use when configuring Purview.BuildSdk through Directory.Build.props or a .csproj, especially for NamespacePrefix, version detection, testing framework selection, telemetry, repo bootstrapping, and embedded agent-skill settings." ---- - -# Purview.BuildSdk configuration reference - -Use this skill when a task asks what can be configured in `Purview.BuildSdk`, where a property must be set, or which defaults the SDK applies automatically. - -## First rule: know where a property must be set - -Set repo-wide bootstrap properties **before** importing the SDK in `Directory.Build.props` when the value must affect `Sdk.props` evaluation. - -Common pre-import properties: - -- `NamespacePrefix` -- `UsePackageJsonVersion` -- `RootPackageJson` -- Repo-wide testing framework selection properties when you want every project to inherit them - -If a property changes behavior in `Sdk.targets` instead, it can usually be set later (for example in a project file), but prefer repo-wide defaults in `Directory.Build.props` unless the scenario is intentionally project-specific. - -## Version detection settings - -These properties control package/app version resolution from `package.json`: - -- `UsePackageJsonVersion` — default `true`; supported values: `true`, `false`, `Strict` -- `RootPackageJson` — explicit path to the `package.json` to read -- `EnableVersionDetectionCache` — default `true`; enables local caching of resolved version data -- `VersionDetectionCacheFile` — optional explicit cache file path -- `VersionDetectionLogEnabled` — default `false`; set to `true` to log the detected package version - -Behavior rules: - -1. If `RootPackageJson` is set, the SDK uses that path. -2. Otherwise it tries to discover the repo root from CI variables, `.git`, or a nearby `package.json`. -3. When version detection succeeds, both `Version` and `PackageVersion` are set from the `version` field. -4. `UsePackageJsonVersion=Strict` should be treated as “fail if discovery/resolution cannot succeed”. - -## Core identity and build settings - -These are the most important configurable properties exposed by the SDK: - -- `NamespacePrefix` — required unless `DisableNamespacePrefixCheck=true` -- `DisableNamespacePrefixCheck` — default `false` -- `DisablePurviewStylePolicyValidation` — default `false`; set to `true` to stop the build failing (`PRSGD0006`-`PRSGD0009`) when the repository `.editorconfig` overrides the modifier policy (`dotnet_style_require_accessibility_modifiers` other than `omit_if_default`), hides `IDE0040`/`IDE1006`, disables the Style category in bulk, weakens the `_camelCase` private instance field naming rule, or adds the accessibility rules to `NoWarn`. Entries the SDK injects itself (the test-context rule set for test/shared-testing projects, `CA1515` for Aspire hosts and CLI apps) are ignored -- `TargetFramework` — defaults to `net10.0` when neither `TargetFramework` nor `TargetFrameworks` is set; projects explicitly declaring `IsRoslynComponent=true` default to `netstandard2.0` -- `IsRoslynComponent` — 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`, no dependency file, compiler-generated output under the framework-specific intermediate directory, telemetry exclusion, and `PrivateAssets=all` applied to `Microsoft.CodeAnalysis.*` / `Microsoft.CodeAnalysis.Analyzers` references. Packable Roslyn components automatically pack the built analyzer assembly and PDB into `analyzers/dotnet/cs/`; a pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack if the compiler defaults are missing unless `DisableRoslynCompilerDefaultsValidation=true` -- `IsRoslynComponentOnly` — defaults to `true` for Roslyn components and creates an analyzer-only package: it sets `IncludeBuildOutput=false`, `IncludeSymbols=false`, and packages the portable PDB alongside the analyzer under `analyzers/dotnet/cs/`. Set it to `false` for a dual-role Roslyn component that uses normal library symbol packaging. -- `PackProjectReferencedSourceGenerators` — default `true`; packable projects automatically include analyzer `ProjectReference` outputs and runtime dependencies under `analyzers/dotnet/cs/`. Set it to `false` globally or use `Pack="false"` on one analyzer reference to opt out. -- `EnableAssemblyNameGeneration` — default `true`; when `true`, `AssemblyName` and default `PackageId` follow the fully evaluated `RootNamespace` (or the full logical project name when suffix-stripping removed a segment, e.g. `Shared`/`ServiceDefaults`). Set `false` before the SDK import to use the standard project-name behaviour -- `PurviewSharedTestingOutputType` — default `Library`; forced onto `IsSharedTestingProject` projects (with `IsTestProject`/`IsTestingPlatformApplication` cleared), because the test packages otherwise flip them into an executable test host. Set it to `Exe` before the SDK import to keep that package-driven shape -- `PurviewTestContextNoWarn` — default `CA1002;CA1012;CA1034;CA1047;CA1050;CA1051;CA1062;CA1064;CA1515;CA1707`; the production API-surface rules exempted in test and shared-testing projects. Test projects keep the strict style contract (`IDE0040`, field naming, formatting, `IDE1006`), but are context aware: public test classes/fixtures, `Method_Scenario_Expectation` names, exposed fields and unvalidated helper parameters are allowed. Override before the SDK import to narrow or extend the set -- `DisablePurviewTestContextRuleSet` — default `false`; set to `true` to make test and shared-testing projects enforce the production API-surface rules as well -- `DisableProjectFileNamingConventionCheck` — default `false`; disables the directory-name/file-name match validation -- `DisableGenerateAssemblyInfoClass` — default `false`; disables generated `AssemblyInfo` -- `DisableAutoInternalsVisibleTo` — default `false`; disables automatic friend assembly generation -- `AutoIncludeUsings` — default `true`; controls SDK-added global usings -- `SourceLinkPackageName` — default `Microsoft.SourceLink.GitHub` -- `DisableSourceLink` — default `false` - -## Telemetry and package-related settings - -- `ExcludePurviewTelemetry` — default `false`; removes `Purview.Telemetry.SourceGenerator` -- `ExcludeMSTelemetryExtension` — default `false`; removes `Microsoft.Extensions.Telemetry.Abstractions`. Only relevant when `ExcludePurviewTelemetry` is also `false` — when `ExcludePurviewTelemetry=true` the whole telemetry group is skipped anyway -- `IsPackable` — defaults to `false` if not set elsewhere -- `PackageTags`, `IncludeSource`, `IncludeSymbols`, `PublishRepositoryUrl`, `SymbolPackageFormat` — standard pack-related settings the SDK participates in for packable projects -- Packable-project defaults (only applied when the consuming project has not supplied a value): `GenerateDocumentationFile=true`, `IncludeSymbols=true`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl=true`, `EmbedUntrackedSources=true`, `DebugType=portable`. Portable PDBs are delivered through the `.snupkg`; the normal `.nupkg` does not receive PDB files unless the project opts in explicitly. Roslyn-component-only packages default `IncludeSymbols=false` and ship their PDB inside `analyzers/dotnet/cs/` instead -- If the repo root is discoverable, the repository-root `README.md` is packed automatically (and registered via `PackageReadmeFile`) when the file exists and `PackageReadmeFile` was not configured explicitly - -## Test framework settings - -The SDK supports opinionated testing defaults and validation. - -Primary settings: - -- `TestingFramework` — default `TUnit`; supported values: `TUnit`, `Xunit`, `None` -- `SubstituteFramework` — default `TUnitMocks`; supported values: `TUnitMocks`, `NSubstitute`, `None` -- `TestDataFramework` — default `Bogus`; supported values: `Bogus`, `None` - -Default outcome for standard test projects: - -- `TUnit` -- `TUnit.Mocks` -- `Bogus` -- Microsoft.Testing.Platform integration - -Specialised packages such as `TUnit.Aspire` and `Purview.Containers` are not automatic defaults; they -remain explicit choices based on the project's purpose. `Purview.Containers` is the container-backed -integration-test dependency; reference `Testcontainers` directly only where no typed module exists yet. - -Related toggles and derived settings: - -- `CollectCoverage` — defaults to `true` for detected test projects -- `EnableStaticNativeInstrumentation` — defaults to `false` for test projects -- `EnableDynamicNativeInstrumentation` — defaults to `false` for test projects -- `TestingPlatformDotnetTestSupport`, `UseMicrosoftTestingPlatformRunner`, `EnableMicrosoftTestingPlatform` — enabled automatically for TUnit test projects - -## Repo bootstrap and developer-experience settings - -These settings control the SDK’s repo-level helper file bootstrapping: - -- `DisableAutoCopySdkFiles` — default `false`; master switch for SDK-managed repo file copying -- `BootstrapEditorConfigToRepoRoot` — default `true` -- `RepositoryEditorConfigFilePath` — optional override for the destination `.editorconfig` -- `BootstrapGlobalJsonToRepoRoot` — default `true` -- `RepositoryGlobalJsonFilePath` — optional override for the destination `global.json` -- `PurviewBuildSdkVersionForGlobalJson` — defaults to detected SDK package version, fallback `1.0.0` -- `PurviewAutoSdkPack` — default `true`; when `true`, automatically packs the `Sdk/` folder contents into the NuGet package with the correct root-level paths -- `EnableAgentFolderInPackage` — default `true`; mirrors the bundled `.agents/**` folder from the SDK NuGet package into the consuming repo’s `.agents/` -- `AgentPackDestinationFolder` — default `.agents`; repo-relative destination folder that receives mirrored agent content as `$(AgentPackDestinationFolder)/**` -- `PurviewAgentFolderSourcePath` — overrides the folder that provides the bundled `.agents` content (defaults to the package-level `.agents` folder beside `Sdk/`) -- `PurviewAgentFolderCopyRetries` — default `3`; copy attempts per file before a failure is reported -- `PurviewAgentFolderCopyRetryDelayMilliseconds` — default `500`; base delay between copy attempts -- `PurviewAgentFolderCopyFailureAsError` — default `true`; when `false`, a copy that still fails after every retry is a warning instead of an error -- `PurviewAgentSyncManifestPath` — overrides the change-detection manifest (default `/.purview/agent-sync.cache`) used to skip unchanged agent content -- `PurviewSuppressCopyRetryWarnings` — default `true`; demotes built-in copy task retry notices (`MSB3026`) to messages. Set to `false` to see every retry attempt - -## Shared repository copy behaviour - -`.agents` content, `.editorconfig` and `global.json` live in one repository-wide location but are written -by every project, so parallel builds race for the same destinations. The SDK therefore: - -1. Skips unchanged content using the `.purview/agent-sync.cache` manifest (fingerprint + content hash per file), so repeat builds touch nothing. This also detects an in-place package republish that keeps the same version. -2. Stages every write into a temporary file in the destination folder and renames it into place, so readers never see partial content and writers cannot interleave. -3. Retries quietly — retry attempts are low-importance messages, `MSB3026` notices are demoted — and only reports a copy that still fails after `Purview*CopyRetries` attempts, as an error by default. -4. Treats "another project already wrote identical content" as success, so a lost race is a no-op instead of a failure. - -`PurviewRepoBootstrapMode` (`IfMissing` default, or `Always`/`WarnOnDrift`/`Never`) controls whether -existing `.editorconfig`/`global.json` files may be overwritten or reported as drifted. - -**Hard requirement:** This SDK must pack the contents of `Sdk/` into the NuGet package so that downstream consumers of `Purview.BuildSdk` receive the same `Sdk/**` files. The `PurviewAutoSdkPack` feature (default `true`) is the mechanism that delivers this for standard consuming projects. When a project is packable, the SDK automatically adds `Sdk/**/*` as package content with the correct root-level paths: - -- `Sdk/.agents/**` → `.agents/**` -- `Sdk/.github/**` → `.github/**` -- `Sdk/build/**` → `build/**` -- `Sdk/buildTransitive/**` → `buildTransitive/**` -- `Sdk/buildMultiTargeting/**` → `buildMultiTargeting/**` -- `Sdk/*.md`, `Sdk/*.png`, `Sdk/*.jpg`, etc. → package root -- everything else under `Sdk/` → `Sdk/` - -The SDK injects a `.gitignore` file into each second-level folder under `Sdk/.agents` during packaging with the following content: - -```text[.gitignore] -# Ignore all files -* - -# Don't ignore directories, so Git can traverse them -!*/ - -# Keep this file -!.gitignore -``` - -This lets consuming repos keep the agent folder structure discoverable while ignoring the copied content in Git. - -## Important derived properties you can inspect - -When explaining SDK behavior, prefer these derived values over guessing: - -- `PurviewLogicalProjectName` -- `PurviewNamespacePrefix` -- `PurviewProjectShortName` -- `PurviewTestType` -- `PurviewSharedTestingOutputType` (default `Library`; `Exe` keeps the test packages' executable/test-host shape) -- `PurviewTestContextNoWarn` (production API-surface rules exempted in test/shared-testing projects) -- `PurviewPolicyExemptNoWarn` (SDK-injected `NoWarn` entries that `ValidatePurviewStylePolicy` accepts) -- `RootNamespace` -- `AssemblyName` -- `PackageVersion` -- `TestingType` -- `TargetProjectName` -- `RepoRoot` -- `RootPackageJson` - -## Compiler-visible properties - -The SDK exports many properties for analyzers and source generators through `build_property.`. When authoring analyzers or generators, prefer those exported properties instead of re-deriving SDK behavior manually. - -Especially relevant exported properties include: - -- `UsePackageJsonVersion`, `RootPackageJson`, `RepoRoot`, `Version`, `PackageVersion` -- `NamespacePrefix`, `DisableNamespacePrefixCheck` -- `TestingFramework`, `SubstituteFramework`, `TestDataFramework` -- `ExcludePurviewTelemetry`, `ExcludeMSTelemetryExtension` -- `EnableAssemblyNameGeneration`, `DisableAutoInternalsVisibleTo`, `DisableGenerateAssemblyInfoClass` -- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject` -- `TestingType`, `TargetProjectName` -- `IsContainerProject`, `IsSdkProject`, `SdkProjectName`, `IsWebProject`, `IsWebSdkProject`, `IsWorkerSdkProject`, `IsAspireHostProject`, `IsCLIProject` -- `EditorConfigFilePath`, `RepositoryEditorConfigFilePath`, `BootstrapEditorConfigToRepoRoot` -- `RepositoryGlobalJsonFilePath`, `BootstrapGlobalJsonToRepoRoot`, `DisableAutoCopySdkFiles` -- `PurviewRepoBootstrapMode`, `PurviewRepoBootstrapCopyRetries`, `PurviewRepoBootstrapCopyRetryDelayMilliseconds`, `PurviewRepoBootstrapCopyFailureAsError` -- `PurviewAgentFolderSourcePath`, `PurviewAgentFolderCopyRetries`, `PurviewAgentFolderCopyRetryDelayMilliseconds`, `PurviewAgentFolderCopyFailureAsError`, `PurviewAgentSyncManifestPath`, `PurviewSuppressCopyRetryWarnings` -- `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile` - -## Guidance for edits - -When changing SDK configuration: - -1. Preserve existing defaults unless the task explicitly changes product behavior. -2. Keep README, SDK property declarations, validation, and any shipped skills aligned. -3. If you add a new user-facing property, update both the configuration docs and the bundled skills. -4. If the property affects import-time behavior, document that it must be set before the SDK import. -5. Keep repository policy guidance aligned with the engineering-principles documentation, and keep low-level - property explanations aligned with the wiki reference pages. diff --git a/src/src/BuildSdk/Sdk/.agents/skills/sdk-engineering-principles/SKILL.md b/src/src/BuildSdk/Sdk/.agents/skills/sdk-engineering-principles/SKILL.md deleted file mode 100644 index be383e2..0000000 --- a/src/src/BuildSdk/Sdk/.agents/skills/sdk-engineering-principles/SKILL.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -name: sdk-engineering-principles -description: "Use when creating or rationalising a repository that uses Purview.BuildSdk and you need the policy-level conventions for project placement, naming, namespace identity, test categories, and large-suite readability." ---- - -# Purview.BuildSdk engineering principles - -Use this skill when the question is not just "what property does the SDK set?" but "how should this -repository be structured so the SDK can work predictably?" - -## First principle - -Treat naming, placement, and test structure as configuration. - -The SDK infers namespaces, identities, categories, package wiring, and project references from a small set -of conventions. The more a repository follows those conventions, the less it needs bespoke overrides. - -## Canonical defaults - -- Prefer a `src/` + `tests/` split for new repositories. -- Keep solution entry points under `src/{SolutionName}.slnx`. -- Keep source projects under `src/src/{ProjectName}/{ProjectName}.csproj`. -- Keep test projects under `src/tests/{ProjectName}.{TestType}Tests/{ProjectName}.{TestType}Tests.csproj`. -- Keep the `.csproj` filename equal to its containing directory name. - -## Identity rules - -- `NamespacePrefix` is the root identity source. -- Use short project names; let the SDK apply the prefix. -- `RootNamespace` is the canonical code identity by default. -- `AssemblyName` and `PackageId` usually follow the resolved project identity. -- When suffix stripping would collapse distinct artifacts, `AssemblyName` and `PackageId` keep the fuller - logical identity. - -Examples: - -- `NamespacePrefix=Aspire`, project `Hosting` -> `Aspire.Hosting` -- `NamespacePrefix=Acme.Sales.RegionalPipeline`, project `Identity.API` -> - `Acme.Sales.RegionalPipeline.Identity.API` -- `NamespacePrefix=Acme.Sales.RegionalPipeline`, project `Identity.Core` -> `RootNamespace` - `Acme.Sales.RegionalPipeline.Identity`, but a distinct assembly/package identity that keeps `Core` - -## Common test project types - -Prefer these by default: - -- `UnitTests` -- `IntegrationTests` -- `E2ETests` -- `FunctionalTests` -- `ContractTests` - -The SDK supports more suffixes, but use them only when the test type itself is important enough to carry in -the project name. - -## Test categories - -- The detected test type becomes the baseline category automatically. -- Additional categories are allowed. -- Add more categories when they improve discoverability for large suites. - -## Default test stack - -Standard test projects receive these by default: - -- `TUnit` -- `TUnit.Mocks` -- `Bogus` -- Microsoft.Testing.Platform integration - -Specialized additions remain explicit: - -- `TUnit.Aspire` for Aspire lifecycle/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. Experimental and prerelease, so pin the version. Reference `Testcontainers` - directly only where a module does not exist yet - -## Test readability rules - -For subject-based tests: - -- Prefer `{SubjectName}Tests` for the class. -- Prefer `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}` for methods. -- Treat methods, constructors, properties, operators, conversions, and validation hooks as valid subjects. - -For non-subject-based suites: - -- Broader names are allowed when they are more truthful and readable. -- Use TUnit display names, categories, and data-driven metadata to keep the suite navigable. - -## When to use this skill vs others - -- Use this skill for policy, structure, naming, and repository-shape questions. -- Use `sdk-project-behavior-and-detection` for "why did the SDK classify this project this way?" -- Use `sdk-configuration-reference` for property-level questions. -- Use `project-placement-defaults` when physically creating or moving projects. diff --git a/src/src/BuildSdk/Sdk/.agents/skills/sdk-project-behavior-and-detection/SKILL.md b/src/src/BuildSdk/Sdk/.agents/skills/sdk-project-behavior-and-detection/SKILL.md deleted file mode 100644 index d57c687..0000000 --- a/src/src/BuildSdk/Sdk/.agents/skills/sdk-project-behavior-and-detection/SKILL.md +++ /dev/null @@ -1,197 +0,0 @@ ---- -name: sdk-project-behavior-and-detection -description: "Use when explaining why Purview.BuildSdk classified a project as test, shared, CLI, web, Aspire host, or container, or when reasoning about auto-added packages, project references, namespaces, and naming conventions." ---- - -# Purview.BuildSdk project behavior and detection - -Use this skill when a task asks **why** the SDK applied a behavior automatically, or when adding/moving projects in a repo that relies on the SDK's naming and project-type inference. - -## Project-type detection rules - -The SDK infers behavior from project names, project contents, and SDK declarations. - -### Test detection - -A project is treated as a test project when its name matches `^.*\.([\w|_]\w*)Tests?$` — a target name, a dot, then any word before `Test` or `Tests`. The captured word becomes `TestingType`. - -This is a pattern, **not** a lookup against a fixed list. `MyProject.GoldenTests` is detected just as readily as `MyProject.UnitTests`, and gets `TestingType=Golden` and `Category=Golden`, even though `Golden` is not a name the SDK ships. - -The dot is required. A project named plainly `UnitTests`, with no target in front of it, does **not** match: it is not detected as a test project at all, and silently gets no runner, no coverage, no `InternalsVisibleTo` and no category. Always name a test project `.Tests`. - -The SDK does ship an explicit `TestType` item group of 53 names (`Unit`, `Integration`, `E2E`, `EndToEnd`, `Acceptance`, `Functional`, `Performance`, `Load`, `Smoke`, `Stress`, `Regression`, `Security`, `Chaos`, `Scenario`, `System`, `Threat`, `BlackBox`, `WhiteBox`, `GrayBox`, `GreyBox`, `Accessibility`, `Usability`, `Visual`, `Interactive`, `Localization`, `Globalization`, `Environment`, `Configuration`, `Deployment`, `Architecture`, `Contract`, `Component`, `Sanity`, `Property`, `Fuzz`, `Mutation`, `Snapshot`, `Approval`, `Benchmark`, `Capacity`, `Concurrency`, `Endurance`, `Soak`, `Spike`, `Failover`, `Recovery`, `Resilience`, `Compatibility`, `Interoperability`, `Migration`, `Upgrade`, `Penetration`, `Compliance`), but it governs **`InternalsVisibleTo` generation**, not detection: a non-test project grants access to `{AssemblyName}.{TestType}Tests` for each name on it. A suffix outside the group still builds, still runs and still gets its category — it just cannot reach the target project's `internal` members. - -Derived properties: - -- `IsTestProject=true` -- `TestingType=` -- `PurviewTestType=Tests` -- `TargetProjectName=` - -### Shared project detection - -The SDK recognizes shared project names exactly. These are not generic substring matches. - -Shared project names: - -- `Shared` -- `SharedFramework` -- `SharedInfrastructure` -- `SharedInfra` -- `SharedUtilities` -- `SharedUtils` -- `SharedLibrary` -- `SharedLib` -- `SharedHelpers` - -Shared testing project names: - -- `SharedTestingFramework` -- `SharedTestingInfrastructure` -- `SharedTestingInfra` -- `SharedTestingUtilities` -- `SharedTestingUtils` -- `SharedTestingLibrary` -- `SharedTestingLib` -- `SharedTestingHelpers` - -Derived flags: - -- `IsSharedProject` -- `IsSharedTestingProject` - -### SDK/content-based detection - -- `IsSdkProject` / `SdkProjectName` come from parsing the project/import `Sdk="..."` declaration -- `IsWebSdkProject=true` for `Microsoft.NET.Sdk.Web` -- `IsWorkerSdkProject=true` for `Microsoft.NET.Sdk.Worker` -- `IsAspireHostProject=true` when the SDK starts with `Aspire.Sdk.Host` or `Aspire.AppHost.Sdk` -- `IsContainerProject=true` when `Dockerfile`, `dockerfile`, or `Dockerfile.dev` exists in the project directory -- `IsCLIProject=true` when the project name ends with `CLI`, `Console`, `CommandLine`, `QuickStart`, or `QuickStarts` - -## Namespace and identity behavior - -The SDK derives the project identity from `NamespacePrefix` and the project name. - -Key behavior: - -1. `PurviewLogicalProjectName` is built from `NamespacePrefix` plus the project name, with deduplication when the project name already starts with the namespace tail. -2. `RootNamespace` defaults to `PurviewLogicalProjectName`. -3. Known suffixes are stripped from `RootNamespace`, including shared/shared-testing names and common segments like `Core`, `EF`, `Shared`, `ClientShared`, and `ServiceDefaults`. -4. Test suffixes are removed from `RootNamespace`, so `Acme.Api.UnitTests` still maps back to `Acme.Api`. -5. `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace` (the canonical default public name) — except when suffix-stripping removed a segment of the logical project name (e.g. `Shared` or `ServiceDefaults`), in which case they use the full `PurviewLogicalProjectName` so those assemblies/packages stay distinct from their parent. Test/shared-testing projects keep their detected suffix in `AssemblyName`/`PackageId` so test assemblies stay distinct. Explicit `AssemblyName`/`PackageId`/`RootNamespace` values always win. -6. The naming defaults are applied during `Sdk.props` evaluation (before the Microsoft SDK computes `TargetName`), so the compiled output name always matches `AssemblyName`. -7. The SDK ships `Purview.BuildSdk.Analyzers` and adds it as an `` item to every C# project, so its rules (PDS0002 Extensions namespace, PDS0003 explicit types with target-typed `new()`, PDS0004 correct acronym capitalization) surface in both command-line builds and Visual Studio. The code-fix assembly ships beside it and is referenced as an `` item inside Visual Studio so the IDE discovers its code fixes. `PDS0004` follows .NET naming guidance for well-known framework spellings (`Sql`, `Guid`, `Uuid`, `Url`, `Dns`, `Tcp`, `Http`, `Xml`, `Db`, ...) and exempts them by default — `Db` is exempt so `DbContext`/`DbConnection`/`DbSet` are never flagged (re-enable via `acronym_map = Db:DB`); a small set — `Api`, `Ai`, `Ui`, `Io`, `Os`, `Cpu`, `Gpu`, `Cli`, `Gui`, `Ram`, `Ssh` — is still renamed to uppercase. Members mandated by a contract (interface implementations, base-class overrides) are never renamed. Customise via `dotnet_analyzer_configuration.pds0004.allowed_words` (exempt segments), `.acronym_map` (segment renames, e.g. `Sql:SQL`), and `.allowed_identifiers` (brand names exempted by exact name or word-boundary prefix, first match wins — e.g. `CosmosDb` covers `CosmosDbServer`). All three merge with and override the shipped defaults. The shipped `.editorconfig` treats generated content (`Migrations/`, `*.g.cs`, `Generated/`, `*.Designer.cs`, `obj/`/`bin/`) as `generated_code = true`, and suppresses namespace-conflict diagnostics under `Extensions/` so no `#pragma` suppressions are needed there. A VS code refactoring (`Split extensions class into one class per receiver type`) is offered on static extensions classes that target multiple receiver types: it splits them into one `Extensions` class per receiver (a generic `this TBuilder where TBuilder : IHostApplicationBuilder` receiver becomes `HostApplicationBuilderExtensions`) and places each under `Extensions//` so the PDS0002 convention stays satisfied. A companion refactoring (`Move extensions class to conventional location`) is offered on single-receiver extensions classes that are misplaced: it re-paths the file to `Extensions//`, fixes the namespace to the receiver's namespace, and updates `using` directives in other referencing documents so the move compiles. - -Do not hand-author alternate namespace conventions unless the repository explicitly opts out of the SDK defaults. - -## Automatic project references - -The SDK adds project references based on layout conventions. - -### Non-test projects - -For ordinary non-test, non-shared projects, it automatically looks for sibling shared projects: - -- `../Shared*/Shared*.csproj` - -It also removes accidental self/shared-testing matches. - -### Test projects - -For detected test projects, it attempts these target-project paths in order when they exist: - -- `../$(TargetProjectName)/$(TargetProjectName).csproj` -- `../../$(TargetProjectName)/$(TargetProjectName).csproj` -- `../src/$(TargetProjectName)/$(TargetProjectName).csproj` -- `../../src/$(TargetProjectName)/$(TargetProjectName).csproj` - -It also adds sibling shared-testing project references via: - -- `../SharedTesting*/SharedTesting*.csproj` - -This is why consistent naming and placement matter so much in repos that use the SDK. - -## Automatic framework/package behavior - -### For non-test C# projects - -- Adds SourceLink unless `DisableSourceLink=true` -- Adds Purview telemetry packages unless `ExcludePurviewTelemetry=true` -- Generates documentation files (`GenerateDocumentationFile=true`) unless explicitly disabled -- Generates `InternalsVisibleTo` attributes unless `DisableAutoInternalsVisibleTo=true` - -### For packable projects - -- Defaults `GenerateDocumentationFile`, `IncludeSymbols`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl`, `EmbedUntrackedSources`, `IncludeSource`, and `DebugType=portable` — only when the consuming project has not supplied a value -- Delivers portable PDBs via the `.snupkg`; the normal `.nupkg` does not receive PDBs unless the project opts in explicitly -- Packs the repository-root `README.md` (registered via `PackageReadmeFile`) when the file exists and `PackageReadmeFile` is unset; skips when a README is already being packed -- Non-packable projects (including web apps) default `WarnOnPackingNonPackableProject=false` so solution-wide pack operations skip them silently - -### For Roslyn component (analyzer/source-generator) projects - -- Defaults a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, and SourceLink with `EmbedUntrackedSources=true` -- `IsRoslynComponentOnly` defaults to `true`, excluding normal build output (`IncludeBuildOutput=false`) and setting `IncludeSymbols=false`; no `.symbols.nupkg` or `.snupkg` is produced and the analyzer PDB ships inside the main `.nupkg` under `analyzers/dotnet/cs/` beside the analyzer assembly (`PurviewPackAnalyzerPdb=true`). Set it to `false` for a dual-role component that uses normal library symbol packaging. -- Packable Roslyn components automatically pack the built analyzer assembly (and PDB) into `analyzers/dotnet/cs/`; `SymbolPackageFormat` defaults to the modern `snupkg` if symbols are explicitly opted into -- `Microsoft.CodeAnalysis.*` and `Microsoft.CodeAnalysis.Analyzers` references are defaulted to `PrivateAssets=all` (development-only dependencies) so they never leak into the packed nuspec -- A pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack of a packable Roslyn component if `LangVersion`, `Nullable`, `TreatWarningsAsErrors`, or `EnforceExtendedAnalyzerRules` is missing; opt out with `DisableRoslynCompilerDefaultsValidation=true` -- `ContinuousIntegrationBuild` is set only by real CI environment variables — packability alone never forces SourceLink's CI-mode dirty-repository checks - -### For test and shared-testing projects - -- Applies test-friendly `NoWarn` defaults -- Marks projects as not packable/publishable -- Adds substitute/test-data/testing packages based on `SubstituteFramework`, `TestDataFramework`, and `TestingFramework` -- For TUnit test projects, enables Microsoft.Testing.Platform integration properties automatically -- For shared-testing projects, skips the runnable test package and marks them with a skip/category pattern appropriate to the selected test framework - -For the default configuration, standard test projects receive: - -- `TUnit` -- `TUnit.Mocks` -- `Bogus` -- Microsoft.Testing.Platform integration - -Specialized testing dependencies such as `TUnit.Aspire` and `Purview.Containers` are still explicit -additions by project purpose. `Purview.Containers` is the container-backed integration-test dependency; -reference `Testcontainers` directly only where no typed module exists yet. - -### For special project types - -- CLI projects default to `OutputType=Exe` and include `appsettings*.json` as content -- Container projects enable `InvariantGlobalization`, `PublishAot`, Linux Docker defaults, and container tooling package references -- Web SDK projects get `Microsoft.AspNetCore.OpenApi.Generated` added to `InterceptorsNamespaces` unless marked as a separate web-project mode -- Aspire host projects default to `OutputType=Exe` - -## How to reason about surprising behavior - -If the SDK “did something unexpected”, inspect these values first: - -- `MSBuildProjectName` -- `NamespacePrefix` -- `PurviewLogicalProjectName` -- `RootNamespace` -- `TestingType` -- `TargetProjectName` -- `SdkProjectName` -- `IsTestProject` -- `IsSharedProject` -- `IsSharedTestingProject` -- `IsContainerProject` -- `IsCLIProject` -- `IsWebSdkProject` -- `IsAspireHostProject` - -Prefer explaining behavior from these computed properties rather than from assumptions about folder names alone. - -## Guidance for structural changes - -When adding or moving projects in a repo using this SDK: - -1. Keep the `.csproj` filename equal to its containing directory name unless the repo explicitly disables that validation. -2. Preserve established `src/` and `tests/`-style layouts whenever possible. -3. Use test project suffixes intentionally so auto-detection and auto-references work. -4. Keep shared helpers in exact shared/shared-testing names if you want the corresponding SDK behavior. -5. If you change a naming rule in the SDK, update the README and the shipped skills together. -6. If the question is really about repository policy rather than one computed property, point the user to the - engineering-principles documentation first, then explain the specific SDK mechanics. diff --git a/src/src/BuildSdk/Sdk/Props/Defaults.props b/src/src/BuildSdk/Sdk/Props/Defaults.props index eeea833..58bd6a8 100644 --- a/src/src/BuildSdk/Sdk/Props/Defaults.props +++ b/src/src/BuildSdk/Sdk/Props/Defaults.props @@ -12,6 +12,9 @@ > latest All + @@ -29,6 +32,8 @@ false false + false false true - true false true true - .agents - true - 3 - 500 - + + true + + false + + + + + + npx + + npx;bunx;bun;pnpm;yarn;deno + + skills@1 + + + .git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json true - + @@ -56,7 +56,7 @@ - + @@ -66,7 +66,7 @@ - + @@ -147,6 +147,8 @@ >false false + false true Opt-out flag that removes Microsoft.Extensions.Telemetry.Abstractions. - - When true, copies the bundled .agents folder from the SDK NuGet package into the consuming repository. Defaults to true. + + + Repository folder holding authored agent skills. Defaults to the repository-root skills/ folder. + + + Comma-separated agents that InstallPurviewSkills installs to. Defaults to '*' (every agent the skills CLI detects). + + + Overrides how the skills CLI is invoked. Empty detects 'skills' then 'npx' on PATH. + + + Installs from an explicit git source (owner/repo, optionally '#ref') instead of from referenced packages. + + + When true, an ordinary build reports missing or version-drifted agent skills. Offline, never installs, never fails the build. Defaults to true. + + + When true, skill recommendations are reported on CI builds as well. Defaults to false. - - Repo-relative destination folder that receives copied .agents content. Defaults to .agents. + + Overrides the stamp used to report each distinct skill state once per repository. Defaults to the repository .purview/skill-recommendations.cache file. - - Overrides the change-detection manifest used to skip unchanged .agents syncs. Defaults to the repository .purview/agent-sync.cache file. + + Overrides where the skills CLI's skills-lock.json is read from. Defaults to the repository root. - - Number of copy attempts used when syncing bundled .agents content into the consuming repository. Defaults to 3. + + Overrides the repository root every skill target runs from. Empty resolves it from PurviewRepositoryRootMarkers. - - Delay in milliseconds between .agents copy retry attempts. Defaults to 500. + + Ordered markers that identify a repository root, most authoritative first. The first is definitive and searched to the top; the rest resolve nearest-first. A trailing slash marks a directory. - - When true, an .agents sync that still fails after every retry is reported as an error instead of a warning. Defaults to true. + + Package runner used to launch the skills CLI (npx, bunx, bun, pnpm, yarn, deno, skills, or any other). Defaults to npx. + + + Runners probed on PATH after PurviewSkillsRunner. Defaults to npx;bunx;bun;pnpm;yarn;deno. + + + Package spec the executed launcher pins to. Defaults to skills@1. Controls repo-level file bootstrapping (IfMissing, Always, WarnOnDrift or Never). Defaults to IfMissing. - - Source folder that provides the bundled .agents content mirrored into the consuming repository. Defaults to the package-level .agents folder beside Sdk/. - + Number of copy attempts used when bootstrapping repo-level files such as .editorconfig and global.json. Defaults to 3. @@ -386,6 +408,9 @@ Disables generation of the AssemblyInfo helper class in intermediate output. + + Disables generation of the assembly identifier marker class used by architecture tests. + When true (default), the SDK derives AssemblyName and PackageId from RootNamespace (or from the full logical project name when suffix-stripping removed a segment, e.g. Shared/ServiceDefaults). Set to false to fall back to the standard .NET behaviour (project name). @@ -456,7 +481,13 @@ Current year value used by generated assembly metadata. - Relative path to the SDK-generated AssemblyInfo source file. + File name of the SDK-generated AssemblyInfo source file, written under the intermediate output Properties folder. + + + File name of the SDK-generated assembly identifier source file, written under the intermediate output Properties folder. + + + 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. @@ -682,12 +713,7 @@ >$([System.IO.Path]::GetFullPath('$(_PurviewDiscoveredEditorConfigFilePath)')) false - - $([MSBuild]::NormalizePath('$(MSBuildThisFileDirectory)', '..', '.agents')) + true $([System.DateTime]::Now.Year) 0.0.1 $(Version) - - $(IntermediateOutputPath)/Properties/GeneratedAssemblyInfo.cs + + GeneratedAssemblyInfo.cs + GeneratedAssemblyIdentifier.cs true - $(TargetsForTfmSpecificContentInPackage);IncludeLinkedSdkFiles;IncludeSdkDotAgentsGitIgnoreFiles;IncludeRepoReadmeInPackage + $(TargetsForTfmSpecificContentInPackage);IncludeLinkedSdkFiles;IncludeRepoReadmeInPackage + + + + + + <_PurviewSkillsRepoRootWasSpecified Condition="'$(PurviewSkillsRepoRoot)' != ''" + >true + $(RepoRoot) + $([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildProjectDirectory)', 'AGENTS.md')) + $([MSBuild]::NormalizeDirectory('$(PurviewSkillsRepoRoot)', 'skills')) @@ -954,9 +1019,6 @@ - - .agents/$([System.String]::Copy('%(RecursiveDir)').Replace("\", "/"))%(Filename)%(Extension) - .github/$([System.String]::Copy('%(RecursiveDir)').Replace("\", "/"))%(Filename)%(Extension) @@ -976,7 +1038,7 @@ Include="Sdk/**/*" Pack="true" Visible="true" - Exclude="Sdk/.agents/**/*;Sdk/.github/**/*;Sdk/build/**/*;Sdk/buildTransitive/**/*;Sdk/buildMultiTargeting/**/*;Sdk/*.md;Sdk/*.png;Sdk/*.jpg;Sdk/*.jpeg;Sdk/*.gif;Sdk/*.svg" + Exclude="Sdk/.github/**/*;Sdk/build/**/*;Sdk/buildTransitive/**/*;Sdk/buildMultiTargeting/**/*;Sdk/*.md;Sdk/*.png;Sdk/*.jpg;Sdk/*.jpeg;Sdk/*.gif;Sdk/*.svg" > Sdk/$([System.String]::Copy('%(RecursiveDir)').Replace("\", "/"))%(Filename)%(Extension) @@ -1034,15 +1096,16 @@ README.md @@ -1125,6 +1188,7 @@ Condition="'$(IsTestProject)' == 'true' OR '$(IsSharedTestingProject)' == 'true'" > true + true - - - - - - - - - - - - - - /'), so the - // ignore cannot reach anything the consuming repository authored. - // - // '.agents/agents/' and '.agents/prompts/' are deliberately NOT covered here. In a - // consuming repository those folders hold the SDK's mirrored .md files *alongside* the - // repository's own authored prompts and agents, so ownership is file-granular rather - // than folder-granular and a blanket ignore would hide the author's own content. The - // mirrored files there are ignored per file by WritePurviewAgentSyncGitIgnore, which - // runs consumer-side where the mirrored set is known. - var files = new List(); - if (Directory.Exists(AgentPackRoot)) - { - foreach (var firstLevelDir in Directory.GetDirectories(AgentPackRoot)) - { - var firstLevelName = Path.GetFileName(firstLevelDir); - foreach (var secondLevelDir in Directory.GetDirectories(firstLevelDir)) - { - var secondLevelName = Path.GetFileName(secondLevelDir); - var gitIgnorePath = Path.Combine(StagingRoot, firstLevelName, secondLevelName, ".gitignore"); - Directory.CreateDirectory(Path.GetDirectoryName(gitIgnorePath)); - File.WriteAllText(gitIgnorePath, GitIgnoreContent); - var item = new TaskItem(gitIgnorePath); - item.SetMetadata("PackagePath", ".agents/" + firstLevelName + "/" + secondLevelName + "/.gitignore"); - files.Add(item); - } - } - } - GitIgnoreFiles = files.ToArray(); - ]]> - - - - - - - - + + @@ -456,7 +411,9 @@ (); + var resolved = new List(); + var unresolved = new List(); + if (File.Exists(ProjectAssetsFile)) { var roots = (NuGetPackageFolders ?? string.Empty) @@ -464,888 +421,998 @@ .Select(root => root.TrimEnd('\\', '/')) .Where(root => root.Length > 0) .ToArray(); - var excludeFullPath = string.IsNullOrEmpty(ExcludeFolder) ? null : Path.GetFullPath(ExcludeFolder); - // project.assets.json "libraries" entries are consistently emitted by NuGet as - // "/": { "sha512": "...", "type": "package", "path": "/", "files": [...] } + // Same "libraries" shape DiscoverAgentPackageFolders reads; the version is the + // segment after the id, and is reported so a diagnostic can name it. var json = File.ReadAllText(ProjectAssetsFile); var libraryPattern = new Regex( - "\"(?[^\"/]+)/[^\"]+\"\\s*:\\s*\\{[^{}]*?\"type\"\\s*:\\s*\"package\"[^{}]*?\"path\"\\s*:\\s*\"(?[^\"]+)\"", + "\"(?[^\"/]+)/(?[^\"]+)\"\\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); + 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; var normalizedPath = libraryPath.Replace('/', Path.DirectorySeparatorChar); + foreach (var root in roots) { - var candidate = Path.GetFullPath(Path.Combine(root, normalizedPath, ".agents")); - if (!Directory.Exists(candidate)) + var packageRoot = Path.Combine(root, normalizedPath); + var skillsFolder = Path.Combine(packageRoot, ".agents", "skills"); + if (!Directory.Exists(skillsFolder)) continue; - if (excludeFullPath != null && string.Equals(candidate, excludeFullPath, StringComparison.OrdinalIgnoreCase)) + // 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 item = new TaskItem(candidate); - item.SetMetadata("PackageId", match.Groups["id"].Value); - results.Add(item); + 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; } } } - AgentFolders = results.ToArray(); + + SkillSources = resolved.ToArray(); + Unresolvable = unresolved.ToArray(); ]]> - - true - $(MSBuildWarningsAsMessages);MSB3026 - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - + - + 0 ? Retries : 3; - var retryDelay = RetryDelayMilliseconds > 0 ? RetryDelayMilliseconds : 500; - var pathComparison = Path.DirectorySeparatorChar == '\\' - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; - - var copied = 0; - var skipped = 0; - var failed = 0; - var upToDate = false; - - bool IsTransient(Exception exception) => - exception is IOException || exception is UnauthorizedAccessException; - - bool IsPermanent(Exception exception) => - exception is ArgumentException - || exception is NotSupportedException - || exception is PathTooLongException - || exception is DirectoryNotFoundException; - - string NormalizeSlashes(string value) => - string.IsNullOrEmpty(value) ? string.Empty : value.Replace('\\', '/'); - - ulong ComputeHash(byte[] bytes) + var sources = SkillSources ?? new ITaskItem[0]; + var unpinnable = UnresolvableSkillSources ?? new ITaskItem[0]; + var agents = string.IsNullOrEmpty(Agents) ? "*" : Agents; + // How the repository launches the CLI - 'npx skills', 'pnpm dlx skills', and so on. + var cli = string.IsNullOrEmpty(CommandPrefix) ? "npx 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) { - unchecked + 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) { - // FNV-1a: dependency-free and identical on every platform, which - // matters because the manifest outlives a single operating system. - var hash = 14695981039346656037UL; - foreach (var value in bytes) + // 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++) { - hash ^= value; - hash *= 1099511628211UL; + 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; } + } } - return hash; - } - } - string ComputeFileHash(string path) => - ComputeHash(File.ReadAllBytes(path)).ToString("x16", CultureInfo.InvariantCulture); + 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*\"(?[^\"]*)\""); - string GetRelativePath(string root, string file) - { - var rootFull = Path.GetFullPath(root); - if (!rootFull.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)) - rootFull += Path.DirectorySeparatorChar; - - var fileFull = Path.GetFullPath(file); - return fileFull.StartsWith(rootFull, pathComparison) - ? NormalizeSlashes(fileFull.Substring(rootFull.Length)) - : NormalizeSlashes(Path.GetFileName(fileFull)); + 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; + } + } + } } - void ClearReadOnlyAttribute(string path) + // ---- 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)) { - try + // Neither of these is ever an agent directory, and '.purview' is this SDK's own. + var ignored = new HashSet(StringComparer.OrdinalIgnoreCase) { - var attributes = File.GetAttributes(path); - if ((attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly) - File.SetAttributes(path, attributes & ~FileAttributes.ReadOnly); - } - catch (Exception e) when (e is IOException || e is UnauthorizedAccessException) + ".git", + ".purview", + }; + + foreach (var directory in Directory.GetDirectories(RepositoryRoot, ".*")) { - // Reported by the retry loop when the write itself fails. + if (ignored.Contains(Path.GetFileName(directory))) + continue; + + if (Directory.Exists(Path.Combine(directory, "skills"))) + { + AgentSkillDirectoriesFound = true; + break; + } } } - bool FilesMatch(string first, string second) - { - var firstInfo = new FileInfo(first); - var secondInfo = new FileInfo(second); - if (!secondInfo.Exists || firstInfo.Length != secondInfo.Length) - return false; + // ---- Classify ---------------------------------------------------------------- + var missing = new List(); + var drifted = new List(); + var unknown = new List(); + var satisfied = new List(); + var orphaned = new List(); - return ComputeFileHash(first) == ComputeFileHash(second); - } + var knownSources = new HashSet(StringComparer.OrdinalIgnoreCase); + var knownOwners = new HashSet(StringComparer.OrdinalIgnoreCase); + var claimedNames = new HashSet(StringComparer.OrdinalIgnoreCase); - bool TextMatches(string first, string second) + foreach (var item in sources.Concat(unpinnable)) { - try - { - // Line endings differ between checkouts (core.autocrlf, .gitattributes), - // so drift detection compares normalized text. - var firstText = File.ReadAllText(first).Replace("\r\n", "\n"); - var secondText = File.ReadAllText(second).Replace("\r\n", "\n"); - return string.Equals(firstText, secondText, StringComparison.Ordinal); - } - catch (Exception e) when (e is IOException || e is UnauthorizedAccessException) - { - return false; - } + 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)); } - // Projects build in parallel, in separate processes, and each one rewrites the whole - // manifest from its own group plus whatever it read of the others. Unsynchronised, two - // of them interleave and the last writer drops the group the other had just added - the - // generated .gitignore then lists only a subset, and the mirrored files it missed show - // up as untracked noise in the consuming repository. Serialising the read-modify-write - // is what makes the merge at the end of it sound. - // - // A named mutex is cross-process on every platform .NET targets. It is scoped to the - // manifest path so two repositories building at once never wait on each other, and the - // wait is bounded so a crashed build can never deadlock one that follows. - void WithManifestLock(string manifestFile, Action action) + foreach (var item in sources) { - var key = NormalizeSlashes(Path.GetFullPath(manifestFile)).ToLowerInvariant(); - var name = "Purview.BuildSdk.AgentSync." - + ComputeHash(Encoding.UTF8.GetBytes(key)).ToString("x16", CultureInfo.InvariantCulture); + 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"); - Mutex mutex = null; - var held = false; - try + 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) { - mutex = new Mutex(false, name); - try + 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)) { - held = mutex.WaitOne(TimeSpan.FromSeconds(30)); + unknownNames.Add(name); + unknownReasons.Add(name + " is installed from '" + installedSource + "', not '" + source + "'"); + continue; } - catch (AbandonedMutexException) + + var installedRef = lockedRef[name]; + if (installedRef.Length == 0) { - // A build that died holding it. The manifest is rebuilt from the file on - // disk either way, so the lock is simply ours now. - held = true; + 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); } - catch (Exception e) when (e is IOException || e is UnauthorizedAccessException || e is NotSupportedException) + + // 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) + "\" --agent \"" + agents + "\" -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) { - // The lock guards against interleaving; it is never a prerequisite. If it - // cannot be taken the merge still runs, just without the guarantee. - Log.LogMessage( - MessageImportance.Low, - "Could not synchronise access to the agent sync manifest '{0}': {1}", - manifestFile, - e.Message + var driftedItem = addItem(drifted, driftedNames); + driftedItem.SetMetadata( + "InstalledRefs", + string.Join(";", driftedNames.Select(name => name + "@" + lockedRef[name])) ); } - // Outside the acquisition handler so it runs exactly once however that went. - try + if (unknownNames.Count > 0) { - action(); + var unknownItem = addItem(unknown, unknownNames); + unknownItem.SetMetadata("Reason", string.Join("; ", unknownReasons)); } - finally - { - if (mutex != null) - { - if (held) - mutex.ReleaseMutex(); - mutex.Dispose(); - } - } + if (satisfiedNames.Count > 0) + addItem(satisfied, satisfiedNames); } - // The entries the manifest records as mirrored, as '/'-rooted paths relative to the - // destination root - the same form the generated .gitignore uses. - List MirroredRelativePaths(List manifestLines) + // 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) { - var paths = new List(); - foreach (var manifestLine in manifestLines) + foreach (var name in (item.GetMetadata("Skills") ?? string.Empty) + .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)) { - // Manifest lines are 'file|group|relpath|...'. - var parts = manifestLine.Split('|'); - if (parts.Length < 3 || parts[0] != "file") - continue; - - var relative = NormalizeSlashes(parts[2]).TrimStart('/'); - if (relative.Length != 0) - paths.Add(relative); + claimedNames.Add(name); } - return paths; + 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); } - // A mirrored file that Git tracks is never ignored, however correct the generated - // .gitignore is - Git applies ignore rules only to untracked paths. The file is then - // rewritten by every upgrade that changes it and shows up as a local modification, which - // reads as the SDK dirtying the working tree. The repository has to untrack it once; the - // SDK cannot, so it says so here rather than leaving it to be discovered as churn. - // - // Only reached when something was actually mirrored this build, so a no-op build spawns - // no process. 'git ls-files' is asked for the destination root as a whole rather than for - // each path, which keeps it to one invocation and clear of command-line length limits. - void WarnOnTrackedMirroredFiles(string root, List manifestLines) + // ---- Orphans ----------------------------------------------------------------- + foreach (var entry in lockedSource.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase)) { - if (DisableMirroredFileTrackingCheck || string.IsNullOrEmpty(root) || !Directory.Exists(root)) - return; - - var mirrored = new HashSet(MirroredRelativePaths(manifestLines), StringComparer.Ordinal); - if (mirrored.Count == 0) - return; + var installedSource = entry.Value; + var slash = installedSource.IndexOf('/'); + if (slash <= 0 || claimedNames.Contains(entry.Key)) + continue; - string output; - try - { - var startInfo = new ProcessStartInfo("git", "ls-files --cached -z -- .") - { - WorkingDirectory = root, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - - using (var process = Process.Start(startInfo)) - { - if (process == null) - return; + // Outside the owners this project references, an entry is the developer's own. + if (!knownOwners.Contains(installedSource.Substring(0, slash)) || knownSources.Contains(installedSource)) + continue; - output = process.StandardOutput.ReadToEnd(); - process.StandardError.ReadToEnd(); - if (!process.WaitForExit(10000) || process.ExitCode != 0) - return; - } - } - catch (Exception e) - { - // Git missing, not a repository, or not permitted to run: the check is advisory, - // so it stays silent rather than failing a build that is otherwise fine. - Log.LogMessage( - MessageImportance.Low, - "Could not check whether mirrored files under '{0}' are tracked by Git: {1}", - root, - e.Message - ); - return; - } + 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); + } - var tracked = new List(); - foreach (var entry in output.Split('\0')) + Missing = missing.ToArray(); + Drifted = drifted.ToArray(); + Unknown = unknown.ToArray(); + Satisfied = satisfied.ToArray(); + Orphaned = orphaned.ToArray(); + + // ---- Deduplicate ------------------------------------------------------------- + // A solution has many projects and they share one repository, so the same advice + // would otherwise be printed once per project, and again on every rebuild. The + // stamp holds a fingerprint of the exact state last reported: advice repeats only + // when the state actually changes. + var fingerprint = new StringBuilder(); + foreach (var pair in new[] + { + new KeyValuePair>("missing", missing), + new KeyValuePair>("drifted", drifted), + new KeyValuePair>("unknown", unknown), + new KeyValuePair>("orphaned", orphaned), + }) + { + foreach (var line in pair.Value + .Select(item => pair.Key + "|" + item.GetMetadata("PackageId") + "|" + + item.GetMetadata("PackageVersion") + "|" + item.GetMetadata("Skills") + + item.GetMetadata("Skill") + "|" + item.GetMetadata("RepositoryCommit")) + .OrderBy(line => line, StringComparer.Ordinal)) { - var candidate = NormalizeSlashes(entry).TrimStart('/'); - if (candidate.Length != 0 && mirrored.Contains(candidate)) - tracked.Add(candidate); + fingerprint.Append(line).Append('\n'); } - - if (tracked.Count == 0) - return; - - tracked.Sort(StringComparer.Ordinal); - - var folderName = new DirectoryInfo(root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)).Name; - var quoted = new StringBuilder(); - foreach (var path in tracked) - quoted.Append(" '").Append(folderName).Append('/').Append(path).Append('\''); - - Log.LogWarning( - subcategory: null, - warningCode: "PurviewTrackedMirroredFile", - helpKeyword: null, - file: Path.Combine(root, ".gitignore"), - lineNumber: 0, - columnNumber: 0, - endLineNumber: 0, - endColumnNumber: 0, - message: "{0} file(s) under '{1}' are mirrored from a NuGet package but tracked by Git, so '{1}/.gitignore' cannot ignore them and every upgrade that changes one shows up as a local modification. Untrack them once - they stay on disk and are rewritten by each build - with 'git rm --cached --{2}'. Suppress with DisableMirroredFileTrackingCheck=true.", - tracked.Count, - folderName, - quoted.ToString() - ); } - // Stage the write next to the destination so the final rename stays - // on one volume and is atomic on Windows, Linux and macOS. - // Writes '/.gitignore' listing every file the manifest records as - // mirrored, so those files stay untracked while anything the repository authored in the - // same folder keeps its normal status. Manifest lines are 'file|group|relpath|...'. - // The file is only rewritten when its content changes, so a no-op build touches nothing. - void WriteAgentSyncGitIgnore(string root, List manifestLines, int attempts, int delay) - { - if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) - return; + // An unrestored lockfile is its own state: restoring it must end the advice. + if (LockFileFound && !AgentSkillDirectoriesFound && lockedSource.Count > 0) + fingerprint.Append("needs-restore\n"); - var paths = new List(); - foreach (var relative in MirroredRelativePaths(manifestLines)) - paths.Add("/" + relative); + var current = fingerprint.ToString(); + ShouldReport = true; - paths.Sort(StringComparer.Ordinal); + if (RespectStamp && !string.IsNullOrEmpty(StampPath)) + { + var previous = string.Empty; + try { if (File.Exists(StampPath)) previous = File.ReadAllText(StampPath); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } - var content = new List - { - "# Generated by Purview.BuildSdk - do not edit.", - "#", - "# These files are mirrored into this folder from NuGet packages on build. Ignoring them", - "# keeps an SDK or package upgrade from showing up as local modifications. Files you", - "# author in this folder are not listed here and are tracked as normal.", - "", - // This file is generated, so it ignores itself rather than being committed - - // otherwise it would show up as a new untracked file in every repository, which - // is the very thing it exists to prevent. Git reads an ignored .gitignore, so - // the entries below still apply. Matches the generated .purview/.gitignore. - "/.gitignore", - }; + ShouldReport = !string.Equals(previous, current, StringComparison.Ordinal); - var seen = new HashSet(StringComparer.Ordinal); - foreach (var path in paths) + // A stamp that cannot be written costs a repeated message, never a failed + // build: a read-only or concurrently-built tree must still build. + if (ShouldReport) { - if (seen.Add(path)) - content.Add(path); + try + { + Directory.CreateDirectory(Path.GetDirectoryName(StampPath)); + File.WriteAllText(StampPath, current); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } } - - var target = Path.Combine(root, ".gitignore"); - var desiredText = new StringBuilder(); - foreach (var contentLine in content) - desiredText.Append(contentLine).Append('\n'); - - try + } + ]]> + + + + + + + + + + + + + + + + + + + + + (); + var seenNames = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (Directory.Exists(SkillsRoot)) + { + var selected = (SkillNames ?? string.Empty) + .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(name => name.Trim()) + .Where(name => name.Length > 0) + .ToArray(); + + foreach (var skillDirectory in Directory.GetDirectories(SkillsRoot).OrderBy(dir => dir, StringComparer.Ordinal)) { - if ( - File.Exists(target) - && string.Equals( - File.ReadAllText(target).Replace("\r\n", "\n"), - desiredText.ToString(), - StringComparison.Ordinal - ) - ) + var folderName = Path.GetFileName(skillDirectory); + if (selected.Length > 0 && Array.IndexOf(selected, folderName) < 0) + continue; + + var skillFile = Path.Combine(skillDirectory, "SKILL.md"); + if (!File.Exists(skillFile)) { - return; + failures.Add(new TaskItem(folderName + ": no SKILL.md. A skill folder without one is invisible to the skills CLI.")); + continue; } - } - catch (Exception e) when (IsTransient(e)) - { - // Fall through and attempt the write. - } - for (var attempt = 1; attempt <= attempts; attempt++) - { - try + var text = File.ReadAllText(skillFile); + var normalized = text.Replace("\r\n", "\n"); + if (!normalized.StartsWith("---\n", StringComparison.Ordinal)) { - WriteAtomically( - target, - temporary => File.WriteAllLines(temporary, content, new UTF8Encoding(false)) - ); - break; + failures.Add(new TaskItem(folderName + ": SKILL.md does not open with YAML frontmatter ('---' on the first line).")); + continue; } - catch (Exception e) when (IsTransient(e)) + + var end = normalized.IndexOf("\n---", 3, StringComparison.Ordinal); + if (end < 0) { - if (attempt < attempts) - { - Thread.Sleep(delay); - } - else - { - Log.LogMessage( - MessageImportance.Low, - "Could not update the mirrored-content .gitignore '{0}': {1}", - target, - e.Message - ); - } + failures.Add(new TaskItem(folderName + ": SKILL.md frontmatter is not terminated by a closing '---'.")); + continue; } - } - } - void WriteAtomically(string destination, Action writeTemporary) - { - var directory = Path.GetDirectoryName(destination); - if (!string.IsNullOrEmpty(directory)) - Directory.CreateDirectory(directory); - - var temporary = destination + "." + Guid.NewGuid().ToString("N") + ".tmp"; - try - { - writeTemporary(temporary); + var frontmatter = normalized.Substring(4, end - 3); + var body = normalized.Substring(end); - if (File.Exists(destination)) + // 'name: value' on its own line. The value is required; a bare key is not a name. + var nameMatch = Regex.Match(frontmatter, "(?m)^name:[ \\t]*(?\\S.*?)[ \\t]*$"); + if (!nameMatch.Success) + failures.Add(new TaskItem(folderName + ": SKILL.md frontmatter has no 'name'. The skills CLI requires it.")); + else { - ClearReadOnlyAttribute(destination); - try - { - File.Replace(temporary, destination, null); - } - catch (Exception e) when (e is PlatformNotSupportedException || e is IOException) + var declaredName = nameMatch.Groups["value"].Value.Trim().Trim('"', '\''); + if (!string.Equals(declaredName, folderName, StringComparison.Ordinal)) { - // Some file systems reject Replace; overwrite in place instead - // and let the retry loop report a genuinely locked destination. - File.Copy(temporary, destination, true); + failures.Add(new TaskItem( + folderName + ": frontmatter name is '" + declaredName + "' but the folder is '" + folderName + + "'. The CLI installs by frontmatter name, so they must match." + )); } + + string firstSeenIn; + if (seenNames.TryGetValue(declaredName, out firstSeenIn)) + failures.Add(new TaskItem(folderName + ": skill name '" + declaredName + "' is already used by '" + firstSeenIn + "'.")); + else + seenNames[declaredName] = folderName; } + + // A description may be inline or a YAML block scalar ('>-' then indented lines), + // so an empty inline value is only a failure when nothing is indented beneath it. + var descriptionMatch = Regex.Match(frontmatter, "(?m)^description:[ \\t]*(?.*)$"); + if (!descriptionMatch.Success) + failures.Add(new TaskItem(folderName + ": SKILL.md frontmatter has no 'description'. It is the activation trigger.")); else { - File.Move(temporary, destination); + var inline = descriptionMatch.Groups["value"].Value.Trim(); + var hasBlock = Regex.IsMatch( + frontmatter.Substring(descriptionMatch.Index + descriptionMatch.Length), + "^\\n[ \\t]+\\S" + ); + if ((inline.Length == 0 || inline == ">-" || inline == ">" || inline == "|" || inline == "|-") && !hasBlock) + failures.Add(new TaskItem(folderName + ": SKILL.md 'description' is empty. It is the activation trigger.")); } - } - finally - { - if (File.Exists(temporary)) + + // Every references/... path the body links to has to exist, or progressive + // disclosure sends the agent to a file that is not there. + foreach (Match reference in Regex.Matches(body, "references/(?[A-Za-z0-9._-]+\\.md)")) { - try - { - File.Delete(temporary); - } - catch (Exception e) when (e is IOException || e is UnauthorizedAccessException) - { - // A leftover temp file is harmless; the next build reuses the name. - } + var referencePath = Path.Combine(skillDirectory, "references", reference.Groups["file"].Value); + if (!File.Exists(referencePath)) + failures.Add(new TaskItem(folderName + ": SKILL.md links to 'references/" + reference.Groups["file"].Value + "', which does not exist.")); } } } - void ReportFailure(string source, string destination, int attempts, string message) - { - failed++; - if (TreatFailureAsError) - { - Log.LogError( - "The Purview SDK could not copy '{0}' to '{1}' after {2} attempt(s): {3}", - source, - destination, - attempts, - message - ); - } - else - { - Log.LogWarning( - "The Purview SDK could not copy '{0}' to '{1}' after {2} attempt(s): {3}", - source, - destination, - attempts, - message - ); - } - } + Failures = failures.ToArray(); + ]]> + + + - bool Finish() + + + + + + + + + + + + + + + { string.Empty }; + if (isWindows) { - UpToDate = upToDate; - CopiedCount = copied; - SkippedCount = skipped; - FailedCount = failed; - return !Log.HasLoggedErrors; + // An empty PATHEXT is not a realistic machine, but defaulting keeps the probe + // working rather than silently finding nothing. + var pathExt = Environment.GetEnvironmentVariable("PATHEXT"); + if (string.IsNullOrEmpty(pathExt)) + pathExt = ".COM;.EXE;.BAT;.CMD"; + + foreach (var ext in pathExt.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)) + extensions.Add(ext.Trim()); } - if (isMirror) + var pathDirectories = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) + .Split(new[] { Path.PathSeparator }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var commandName in CommandNames.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)) { - var sources = Sources ?? Array.Empty(); - var destinationRoot = DestinationRoot ?? string.Empty; - if (sources.Length == 0 || string.IsNullOrEmpty(destinationRoot)) - { - upToDate = true; - return Finish(); - } + var trimmedCommand = commandName.Trim(); + if (trimmedCommand.Length == 0) + continue; - var manifestFile = ManifestPath ?? string.Empty; - if (!string.IsNullOrEmpty(manifestFile)) + foreach (var pathDirectory in pathDirectories) { - // Keep the manifest folder invisible to Git without requiring any - // consumer configuration; the folder contents are build artifacts. + // A malformed PATH entry is common and must not abort the probe. + string directory; try { - var manifestDirectory = Path.GetDirectoryName(manifestFile); - if (!string.IsNullOrEmpty(manifestDirectory)) - { - Directory.CreateDirectory(manifestDirectory); - var ignoreFile = Path.Combine(manifestDirectory, ".gitignore"); - if (!File.Exists(ignoreFile)) - File.WriteAllText(ignoreFile, "*" + Environment.NewLine, new UTF8Encoding(false)); - } + directory = pathDirectory.Trim().Trim('"'); + if (directory.Length == 0) + continue; } - catch (Exception e) when (IsTransient(e)) + catch { - Log.LogMessage( - MessageImportance.Low, - "Could not prepare the agent sync manifest folder '{0}': {1}", - manifestFile, - e.Message - ); + continue; } - } - var previous = new Dictionary(StringComparer.Ordinal); - if (!string.IsNullOrEmpty(manifestFile) && File.Exists(manifestFile)) - { - try + foreach (var extension in extensions) { - foreach (var line in File.ReadAllLines(manifestFile)) + string candidate; + try { - var parts = line.Split('|'); - if (parts.Length >= 6 && parts[0] == "file") - previous[parts[1] + "|" + parts[2]] = parts[3] + "|" + parts[4] + "|" + parts[5]; + candidate = Path.Combine(directory, trimmedCommand + extension); } + catch + { + continue; + } + + if (!File.Exists(candidate)) + continue; + + FoundCommand = trimmedCommand; + FoundPath = candidate; + break; } - catch (Exception e) when (IsTransient(e)) - { - Log.LogMessage( - MessageImportance.Low, - "Ignoring unreadable agent sync manifest '{0}': {1}", - manifestFile, - e.Message - ); - previous.Clear(); - } + + if (FoundCommand.Length > 0) + break; } - var groups = new HashSet(StringComparer.Ordinal); - var assets = new List(); - foreach (var source in sources) - { - var root = source.ItemSpec; - if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) - continue; + if (FoundCommand.Length > 0) + break; + } + ]]> + + + - var group = source.GetMetadata("ChangeGroup"); - if (string.IsNullOrEmpty(group)) - group = NormalizeSlashes(Path.GetFullPath(root)); - groups.Add(group); + + + true + $(MSBuildWarningsAsMessages);MSB3026 + - var desired = new Dictionary(StringComparer.Ordinal); - foreach (var asset in assets) - { - var assetSource = asset.ItemSpec; - var destination = asset.GetMetadata("PurviewDestination"); - var key = asset.GetMetadata("PurviewGroup") + "|" + asset.GetMetadata("PurviewRelativePath"); + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 ? Retries : 3; + var retryDelay = RetryDelayMilliseconds > 0 ? RetryDelayMilliseconds : 500; + var pathComparison = Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; - var value = fingerprint + "|" + hash; - desired[key] = value; + var copied = 0; + var skipped = 0; + var failed = 0; + var upToDate = false; - if ( - File.Exists(destination) - && previous.TryGetValue(key, out var previousValue) - && string.Equals(previousValue, value, StringComparison.Ordinal) - ) + bool IsTransient(Exception exception) => + exception is IOException || exception is UnauthorizedAccessException; + + bool IsPermanent(Exception exception) => + exception is ArgumentException + || exception is NotSupportedException + || exception is PathTooLongException + || exception is DirectoryNotFoundException; + + + ulong ComputeHash(byte[] bytes) + { + unchecked + { + // FNV-1a: dependency-free and identical on every platform, which + // matters because the manifest outlives a single operating system. + var hash = 14695981039346656037UL; + foreach (var value in bytes) { - skipped++; - continue; + hash ^= value; + hash *= 1099511628211UL; } + return hash; + } + } - upToDate = false; - for (var attempt = 1; attempt <= attemptLimit; attempt++) - { - try - { - if (File.Exists(destination) && FilesMatch(assetSource, destination)) - { - // Another project or node already produced identical content, - // so the race is a no-op rather than a failure. - skipped++; - break; - } + string ComputeFileHash(string path) => + ComputeHash(File.ReadAllBytes(path)).ToString("x16", CultureInfo.InvariantCulture); - WriteAtomically(destination, temporary => File.Copy(assetSource, temporary, true)); - copied++; - break; - } - catch (Exception e) when (IsTransient(e)) - { - if (attempt < attemptLimit) - { - Log.LogMessage( - MessageImportance.Low, - "Retrying copy of '{0}' to '{1}' (attempt {2} of {3}): {4}", - assetSource, - destination, - attempt + 1, - attemptLimit, - e.Message - ); - Thread.Sleep(retryDelay + ((attempt - 1) * 100)); - } - else - { - ReportFailure(assetSource, destination, attempt, e.Message); - } - } - catch (Exception e) when (IsPermanent(e)) - { - ReportFailure(assetSource, destination, attempt, e.Message); - break; - } - } + void ClearReadOnlyAttribute(string path) + { + try + { + var attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly) + File.SetAttributes(path, attributes & ~FileAttributes.ReadOnly); + } + catch (Exception e) when (e is IOException || e is UnauthorizedAccessException) + { + // Reported by the retry loop when the write itself fails. } + } + + bool FilesMatch(string first, string second) + { + var firstInfo = new FileInfo(first); + var secondInfo = new FileInfo(second); + if (!secondInfo.Exists || firstInfo.Length != secondInfo.Length) + return false; - if (!string.IsNullOrEmpty(manifestFile) && failed == 0) + return ComputeFileHash(first) == ComputeFileHash(second); + } + + bool TextMatches(string first, string second) + { + try { - // Held across the re-read, the write and the generated .gitignore, because all - // three derive from the merged set and a concurrent writer between any two of - // them is what loses a group. - WithManifestLock(manifestFile, () => - { - var lines = new List { "schema|1" }; - var counts = new Dictionary(StringComparer.Ordinal); - foreach (var pair in desired) - { - var separator = pair.Key.IndexOf('|'); - if (separator < 0) - continue; + // Line endings differ between checkouts (core.autocrlf, .gitattributes), + // so drift detection compares normalized text. + var firstText = File.ReadAllText(first).Replace("\r\n", "\n"); + var secondText = File.ReadAllText(second).Replace("\r\n", "\n"); + return string.Equals(firstText, secondText, StringComparison.Ordinal); + } + catch (Exception e) when (e is IOException || e is UnauthorizedAccessException) + { + return false; + } + } - var group = pair.Key.Substring(0, separator); - counts[group] = counts.ContainsKey(group) ? counts[group] + 1 : 1; - lines.Add("file|" + group + "|" + pair.Key.Substring(separator + 1) + "|" + pair.Value); - } + void WriteAtomically(string destination, Action writeTemporary) + { + var directory = Path.GetDirectoryName(destination); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); - foreach (var group in groups) - lines.Add("group|" + group + "|" + (counts.ContainsKey(group) ? counts[group] : 0)); + var temporary = destination + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + writeTemporary(temporary); - // Entries owned by another target (the packaged agent folders, for - // example) are preserved so both targets can share one manifest. - if (File.Exists(manifestFile)) + if (File.Exists(destination)) { + ClearReadOnlyAttribute(destination); try { - foreach (var line in File.ReadAllLines(manifestFile)) - { - var parts = line.Split('|'); - if ( - parts.Length >= 3 - && (parts[0] == "file" || parts[0] == "group") - && !groups.Contains(parts[1]) - ) - { - lines.Add(line); - } - } - } - catch (Exception e) when (IsTransient(e)) - { - Log.LogMessage( - MessageImportance.Low, - "Could not preserve existing agent sync manifest entries: {0}", - e.Message - ); + File.Replace(temporary, destination, null); } - } - - // A no-op build must not touch repository files at all, so the - // manifest is only written when its content actually changes. - var manifestUpToDate = false; - try - { - if (File.Exists(manifestFile)) + catch (Exception e) when (e is PlatformNotSupportedException || e is IOException) { - var desiredManifest = new StringBuilder(); - foreach (var line in lines) - desiredManifest.Append(line).Append('\n'); - - manifestUpToDate = string.Equals( - File.ReadAllText(manifestFile).Replace("\r\n", "\n"), - desiredManifest.ToString(), - StringComparison.Ordinal - ); + // Some file systems reject Replace; overwrite in place instead + // and let the retry loop report a genuinely locked destination. + File.Copy(temporary, destination, true); } } - catch (Exception e) when (IsTransient(e)) + else { - manifestUpToDate = false; + File.Move(temporary, destination); } - - for (var attempt = 1; attempt <= attemptLimit && !manifestUpToDate; attempt++) + } + finally + { + if (File.Exists(temporary)) { try { - WriteAtomically( - manifestFile, - temporary => File.WriteAllLines(temporary, lines, new UTF8Encoding(false)) - ); - break; + File.Delete(temporary); } - catch (Exception e) when (IsTransient(e)) + catch (Exception e) when (e is IOException || e is UnauthorizedAccessException) { - if (attempt < attemptLimit) - { - Thread.Sleep(retryDelay); - } - else - { - Log.LogMessage( - MessageImportance.Low, - "Could not update the agent sync manifest '{0}': {1}", - manifestFile, - e.Message - ); - } + // A leftover temp file is harmless; the next build reuses the name. } } - // Ignore the mirrored files per file, driven by the manifest just built. - // - // The packaged blanket '.gitignore' only covers folders this SDK wholly owns (a - // skill is its own folder). '.agents/agents/' and '.agents/prompts/' hold mirrored - // .md files beside the repository's own authored prompts and agents, so a blanket - // ignore there would hide the author's content. Listing only the manifest's paths - // keeps an SDK upgrade from dirtying the working tree while leaving authored files - // tracked as normal. - WriteAgentSyncGitIgnore(destinationRoot, lines, attemptLimit, retryDelay); - - // Only when this build actually mirrored something: that is both the moment the - // churn would appear and the moment the advice is actionable. - if (copied > 0) - WarnOnTrackedMirroredFiles(destinationRoot, lines); - }); } - upToDate = upToDate || copied == 0; - Log.LogMessage( - MessageImportance.Low, - "Purview .agents sync: {0} copied, {1} unchanged, {2} failed.", - copied, - skipped, - failed - ); - return Finish(); + } + + void ReportFailure(string source, string destination, int attempts, string message) + { + failed++; + if (TreatFailureAsError) + { + Log.LogError( + "The Purview SDK could not copy '{0}' to '{1}' after {2} attempt(s): {3}", + source, + destination, + attempts, + message + ); + } + else + { + Log.LogWarning( + "The Purview SDK could not copy '{0}' to '{1}' after {2} attempt(s): {3}", + source, + destination, + attempts, + message + ); + } + } + + bool Finish() + { + UpToDate = upToDate; + CopiedCount = copied; + SkippedCount = skipped; + FailedCount = failed; + return !Log.HasLoggedErrors; } var destinationFile = DestinationFile ?? string.Empty; @@ -1585,7 +1652,6 @@ >$(_EffectiveRepositoryEditorConfigFilePath) + + + + + + + + + + + + + (StringComparer.OrdinalIgnoreCase); + try + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(home)) + { + for (var directory = new DirectoryInfo(Path.GetFullPath(home)); + directory != null; + directory = directory.Parent) + { + excluded.Add(directory.FullName.TrimEnd('/', '\\')); + } + } + } + catch (ArgumentException) { } + catch (PathTooLongException) { } + + var ancestors = new List(); + try + { + for (var directory = new DirectoryInfo(Path.GetFullPath(StartDirectory)); + directory != null; + directory = directory.Parent) + { + if (!excluded.Contains(directory.FullName.TrimEnd('/', '\\'))) + ancestors.Add(directory.FullName); + } + } + catch (ArgumentException) { } + catch (PathTooLongException) { } + catch (IOException) { } + + // A trailing slash means "directory". It still matches a file of that name, + // because '.git' is a directory in a clone but a file in a worktree or a + // submodule, and both identify a repository root. + var markers = Markers + .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(entry => entry.Trim()) + .Where(entry => entry.TrimEnd('/', '\\').Length > 0) + .Select(entry => new KeyValuePair( + entry, + entry.EndsWith("/", StringComparison.Ordinal) || entry.EndsWith("\\", StringComparison.Ordinal) + )) + .ToArray(); + + var matches = new Func, bool>((directory, marker) => + { + string candidate; + try { candidate = Path.Combine(directory, marker.Key.TrimEnd('/', '\\')); } + catch (ArgumentException) { return false; } + + return marker.Value + ? (Directory.Exists(candidate) || File.Exists(candidate)) + : File.Exists(candidate); + }); + + // Pass 1: the definitive marker, searched all the way up. + if (markers.Length > 0) + { + foreach (var ancestor in ancestors) + { + if (!matches(ancestor, markers[0])) + continue; + + RepositoryRoot = ancestor; + MatchedMarker = markers[0].Key; + break; + } + } + + // Pass 2: nearest ancestor holding any remaining marker, list order breaking + // ties inside that one directory. + if (RepositoryRoot.Length == 0) + { + foreach (var ancestor in ancestors) + { + foreach (var marker in markers) + { + if (!matches(ancestor, marker)) + continue; + + RepositoryRoot = ancestor; + MatchedMarker = marker.Key; + break; + } + + if (RepositoryRoot.Length > 0) + break; + } + } + } + ]]> + + + + + - <_PurviewAgentSourceRoot Include="@(SourceRoot -> WithMetadataValue('SourceControl', 'git'))" /> + <_PurviewSkillsSourceRoot Include="@(SourceRoot -> WithMetadataValue('SourceControl', 'git'))" /> + - <_PurviewAgentFolderRepoRoot Condition="'$(RepoRoot)' != ''">$(RepoRoot) - <_PurviewAgentFolderAgentsMd Condition="'$(_PurviewAgentFolderRepoRoot)' == ''" - >$([MSBuild]::GetPathOfFileAbove('AGENTS.md', '$(MSBuildProjectDirectory)')) - <_PurviewAgentFolderRepoRoot - Condition="'$(_PurviewAgentFolderRepoRoot)' == '' AND '$(_PurviewAgentFolderAgentsMd)' != ''" - >$([System.IO.Path]::GetDirectoryName('$(_PurviewAgentFolderAgentsMd)'))\ - <_PurviewAgentFolderRepoRoot Condition="'$(_PurviewAgentFolderRepoRoot)' == ''" - >@(_PurviewAgentSourceRoot) - <_PurviewAgentFolderDestinationRoot Condition="'$(_PurviewAgentFolderRepoRoot)' != ''" - >$([MSBuild]::NormalizePath('$(_PurviewAgentFolderRepoRoot)', '$(AgentPackDestinationFolder)')) + <_PurviewSkillsRepositoryRoot Condition="'$(PurviewSkillsInstallRoot)' != ''" + >$(PurviewSkillsInstallRoot) + <_PurviewSkillsRepositoryRoot + Condition="'$(_PurviewSkillsRepositoryRoot)' == '' AND '$(_PurviewSkillsRepoRootWasSpecified)' == 'true'" + >$(PurviewSkillsRepoRoot) + + + + + + + + + + + <_PurviewSkillsRepositoryRoot + Condition="'$(_PurviewSkillsRepositoryRoot)' == '' AND '$(PurviewSkillsRepoRoot)' != ''" + >$(PurviewSkillsRepoRoot) + <_PurviewSkillsRepositoryRoot Condition="'$(_PurviewSkillsRepositoryRoot)' == '' AND '$(RepoRoot)' != ''" + >$(RepoRoot) + + + <_PurviewSkillsRepositoryRoot + Condition="'$(_PurviewSkillsRepositoryRoot)' == '' AND '$(GITHUB_WORKSPACE)' != '' AND Exists('$(GITHUB_WORKSPACE)')" + >$(GITHUB_WORKSPACE) + <_PurviewSkillsRepositoryRoot + Condition="'$(_PurviewSkillsRepositoryRoot)' == '' AND '$(BUILD_SOURCESDIRECTORY)' != '' AND Exists('$(BUILD_SOURCESDIRECTORY)')" + >$(BUILD_SOURCESDIRECTORY) + <_PurviewSkillsRepositoryRoot + Condition="'$(_PurviewSkillsRepositoryRoot)' == '' AND '$(CI_PROJECT_DIR)' != '' AND Exists('$(CI_PROJECT_DIR)')" + >$(CI_PROJECT_DIR) + + <_PurviewSkillsRepositoryRoot Condition="'$(_PurviewSkillsRepositoryRoot)' == ''" + >@(_PurviewSkillsSourceRoot) + + + + + + + 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 + + + + + + + + + + + - - <_PurviewAgentSyncManifestPath>$([MSBuild]::NormalizePath('$(_PurviewAgentFolderRepoRoot)', '.purview', 'agent-sync.cache')) - - - <_PurviewAgentSyncManifestPath>$(PurviewAgentSyncManifestPath) - + + + + + + + + + + + + + - - <_PurviewAgentFolderSyncSource Include="$(PurviewAgentFolderSourcePath)"> - sdk:$(PurviewBuildSdkVersionForGlobalJson) - + + + * + + false + + + + + + $(PurviewSkillsSource) + * + (explicit source) + - + + + - - - + + + + + + + @(PurviewSkillsRunnerLauncher->WithMetadataValue('Identity', '$(_PurviewSkillsCliCommand)')->'%(Exec)') + $(_PurviewSkillsCliCommand) $(PurviewSkillsPackage) + + + + + + + + + + + + + - + + <_PurviewSkillRecommendationRoot Condition="'$(_PurviewSkillRecommendationRoot)' == ''" + >$(_PurviewSkillsRepositoryRoot) + + $([MSBuild]::NormalizePath('$(_PurviewSkillRecommendationRoot)', 'skills-lock.json')) + $([MSBuild]::NormalizePath('$(_PurviewSkillRecommendationRoot)', '.purview', 'skill-recommendations.cache')) + + <_PurviewSkillRespectStamp Condition="'$(_PurviewSkillRespectStamp)' == ''" + >false + * + + <_PurviewSkillsCommandDisplay>@(PurviewSkillsRunnerLauncher->WithMetadataValue('Identity', '$(PurviewSkillsRunner)')->'%(Display)') + <_PurviewSkillsCommandDisplay Condition="'$(_PurviewSkillsCommandDisplay)' == ''" + >$(PurviewSkillsRunner) skills + + + + + + - - - - <_PurviewPackagedAgentFolderSyncSource Include="@(_PurviewPackagedAgentFolder)"> - package:%(PackageId) - - - + + + + + + + + + + + + + + + + + + + + + + + + + + <_PurviewSkillRespectStamp>true + + + + + + + - - <_PurviewSdkDotAgentsRoot>$([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', 'Sdk', '.agents')) - <_PurviewSdkDotAgentsGitIgnoreStagingRoot>$([MSBuild]::NormalizePath('$(IntermediateOutputPath)', '_PurviewSdkDotAgentsGitIgnores')) - - + + + + - - - - - - - %(PackagePath) - - <_PurviewPackedReadme @@ -1778,8 +2371,20 @@ Condition="$([System.String]::Copy('%(TfmSpecificPackageFile.Filename)').ToLowerInvariant()) == 'readme'" /> - - + + + <_PurviewReadmeToPack Condition="Exists('$(MSBuildProjectDirectory)/README.md')" + >$(MSBuildProjectDirectory)/README.md + <_PurviewReadmeToPack + Condition="'$(_PurviewReadmeToPack)' == '' AND '$(RepoRoot)' != '' AND Exists('$(RepoRoot)/README.md')" + >$(RepoRoot)/README.md + + + / @@ -2194,7 +2799,6 @@ <_RepositoryGlobalJsonLines Include="}" /> + + <_AssemblyInfoOutputFile>$(IntermediateOutputPath)Properties/$(AutoGeneratedAssemblyInfoFile) + + - + + + + + + + + + <_AssemblyIdentifierSourceName Condition="'$(_PurviewEscapedNamespacePrefix)' != ''" + >$([System.Text.RegularExpressions.Regex]::Replace('$(MSBuildProjectName)', '^$(_PurviewEscapedNamespacePrefix)($|\.)', '')) + + <_AssemblyIdentifierSourceName Condition="'$(_AssemblyIdentifierSourceName)' == ''" + >$(MSBuildProjectName) + $([System.Text.RegularExpressions.Regex]::Replace('$(_AssemblyIdentifierSourceName)', '[^\w]', ''))Assembly + <_AssemblyIdentifierOutputFile>$(IntermediateOutputPath)Properties/$(AutoGeneratedAssemblyIdentifierFile) + + + + + + + net10.0 + + + Purview.Skills.Testing + Purview.Skills.Testing + Purview.Skills + + false + + + + + + + +
diff --git a/src/tests/ArchitectureTesting.UnitTests/ArchitecturePolicyEvaluatorTests.cs b/src/tests/ArchitectureTesting.UnitTests/ArchitecturePolicyEvaluatorTests.cs new file mode 100644 index 0000000..67c748b --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/ArchitecturePolicyEvaluatorTests.cs @@ -0,0 +1,430 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// +/// Exercises the rule engine directly, against graphs built in memory. +/// +/// +/// The evaluator is deliberately free of MSBuild and test-framework types, which is what lets these +/// tests state a rule as a three-project graph rather than a repository on disk. A rule tested only +/// through a real solution is a rule you can only break by breaking the solution. +/// +public sealed class ArchitecturePolicyEvaluatorTests +{ + static ProjectNode Project( + string name, + IReadOnlyList? references = null, + IReadOnlyList? packages = null, + bool isTest = false + ) => new(name, $"/repo/src/{name}/{name}.csproj", references ?? [], packages ?? [], isTest); + + static ArchitecturePolicyEvaluator Evaluator(ArchitecturePolicy policy, params ProjectNode[] projects) => + new(policy, projects.ToDictionary(project => project.Name, StringComparer.OrdinalIgnoreCase)); + + static ArchitecturePolicy Policy(params Responsibility[] responsibilities) => + new() + { + Version = 1, + Responsibilities = responsibilities, + DiscouragedProjectNames = new DiscouragedNames + { + Names = ["Infrastructure", "Services"], + Reason = "These name a category rather than a responsibility, so nothing can be excluded from them.", + }, + }; + + static Responsibility Core => + new() + { + Name = "Core", + Aliases = ["Domain"], + Category = "domain", + Purpose = "The fundamental domain model and its rules.", + MayDependOn = ["Shared"], + MustNotDependOn = ["Application", "Persistence", "Composition"], + ForbiddenPackagePrefixes = ["Microsoft.EntityFrameworkCore"], + }; + + static Responsibility Application => + new() + { + Name = "Application", + Category = "application", + Purpose = "Coordinates use cases and workflows.", + MayDependOn = ["Core"], + MustNotDependOn = ["Persistence", "Composition"], + }; + + static Responsibility Persistence => + new() + { + Name = "Persistence", + Category = "infrastructure", + Purpose = "Durable data access.", + MayDependOn = ["Core", "Application"], + MustNotDependOn = ["Composition"], + SiblingIndependence = true, + }; + + static Responsibility Shared => + new() + { + Name = "Shared", + Category = "shared", + Purpose = "Small, broadly reusable, domain-independent utilities.", + MayDependOn = [], + MustNotDependOn = [], + }; + + static Responsibility Composition => + new() + { + Name = "Composition", + Category = "composition", + Purpose = "Dependency injection registration and implementation selection.", + MayDependOn = ["Core", "Application", "Persistence"], + MustNotDependOn = [], + }; + + [Test] + public async Task DependencyMatrix_GivenAForbiddenReference_ReportsItWithTheCorrection() + { + // Arrange + var evaluator = Evaluator( + Policy(Core, Application, Persistence), + Project("Core", ["Persistence"]), + Project("Persistence") + ); + + // Act + var violations = evaluator.DependencyMatrix(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Rule).IsEqualTo(ArchitectureRules.DependencyMatrix); + await Assert.That(violations[0].Project).IsEqualTo("Core"); + // The message has to carry the correction, not just the fact. + await Assert.That(violations[0].Message).Contains("must not depend on Persistence"); + await Assert.That(violations[0].Message).Contains("Invert it"); + } + + [Test] + public async Task DependencyMatrix_GivenAPermittedReference_ReportsNothing() + { + // Arrange + var evaluator = Evaluator(Policy(Core, Application), Project("Application", ["Core"]), Project("Core")); + + // Act + var violations = evaluator.DependencyMatrix(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task DependencyMatrix_GivenAnImplementationQualifiedName_ClassifiesByTheFirstSegment() + { + // Arrange + // Persistence.SqlServer is Persistence; qualification is a naming choice, never a different rule. + var evaluator = Evaluator( + Policy(Core, Application, Persistence), + Project("Persistence.SqlServer", ["Core"]), + Project("Core") + ); + + // Act + var violations = evaluator.DependencyMatrix(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task DependencyMatrix_GivenAnAlias_ClassifiesIt() + { + // Arrange + // 'Domain' is a recognised alias for Core, so Core's rules apply to it. + var evaluator = Evaluator( + Policy(Core, Application, Persistence), + Project("Domain", ["Persistence"]), + Project("Persistence") + ); + + // Act + var violations = evaluator.DependencyMatrix(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Project).IsEqualTo("Domain"); + } + + [Test] + public async Task DependencyMatrix_GivenAReferenceThatIsNeitherPermittedNorForbidden_ReportsIt() + { + // Arrange + // Silence on a dependency is not permission; an unlisted edge is a gap in the policy. + // Shared appears in neither Application's mayDependOn nor its mustNotDependOn. + var evaluator = Evaluator( + Policy(Core, Application, Shared), + Project("Application", ["Shared"]), + Project("Shared") + ); + + // Act + var violations = evaluator.DependencyMatrix(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("neither permits nor forbids"); + } + + [Test] + public async Task SiblingIndependence_GivenAQualifiedSiblingReference_ReportsIt() + { + // Arrange + var evaluator = Evaluator( + Policy(Core, Persistence), + Project("Persistence.SqlServer", ["Persistence.PostgreSql"]), + Project("Persistence.PostgreSql") + ); + + // Act + var violations = evaluator.SiblingIndependence(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("must stay independent"); + } + + [Test] + public async Task SiblingIndependence_GivenAReferenceToTheUnqualifiedProject_AllowsIt() + { + // Arrange + // The unqualified project is shared ground, which is the whole reason for having one. + var evaluator = Evaluator( + Policy(Core, Persistence), + Project("Persistence.SqlServer", ["Persistence"]), + Project("Persistence") + ); + + // Act + var violations = evaluator.SiblingIndependence(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task CompositionBoundary_GivenAnImplementationReferencingComposition_ReportsIt() + { + // Arrange + var evaluator = Evaluator( + Policy(Core, Persistence, Composition), + Project("Persistence", ["Composition"]), + Project("Composition") + ); + + // Act + var violations = evaluator.CompositionBoundary(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("circular"); + } + + [Test] + public async Task ForbiddenPackages_GivenAMatchingPrefix_ReportsIt() + { + // Arrange + var evaluator = Evaluator(Policy(Core), Project("Core", packages: ["Microsoft.EntityFrameworkCore.SqlServer"])); + + // Act + var violations = evaluator.ForbiddenPackages(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Rule).IsEqualTo(ArchitectureRules.ForbiddenPackage); + } + + [Test] + public async Task ForbiddenPackages_GivenAnUnrelatedPackage_AllowsIt() + { + // Arrange + var evaluator = Evaluator(Policy(Core), Project("Core", packages: ["System.Text.Json"])); + + // Act + var violations = evaluator.ForbiddenPackages(); + + // Assert + await Assert.That(violations).IsEmpty(); + } + + [Test] + public async Task Cycles_GivenACircularGraph_ReportsThePath() + { + // Arrange + var evaluator = Evaluator( + Policy(Core, Application), + Project("Core", ["Application"]), + Project("Application", ["Core"]) + ); + + // Act + var violations = evaluator.Cycles(); + + // Assert + await Assert.That(violations).IsNotEmpty(); + await Assert.That(violations[0].Message).Contains("->"); + } + + [Test] + public async Task TestReferences_GivenProductionReferencingATestProject_ReportsIt() + { + // Arrange + var evaluator = Evaluator( + Policy(Core), + Project("Core", ["Core.UnitTests"]), + Project("Core.UnitTests", isTest: true) + ); + + // Act + var violations = evaluator.TestReferences(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("must not ship"); + } + + [Test] + public async Task Naming_GivenAnUnknownProjectName_ReportsIt() + { + // Arrange + var evaluator = Evaluator(Policy(Core), Project("Widgets")); + + // Act + var violations = evaluator.Naming(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Message).Contains("Known responsibilities"); + } + + [Test] + public async Task DiscouragedNames_GivenACatchAllName_ReportsIt() + { + // Arrange + var evaluator = Evaluator(Policy(Core), Project("Infrastructure")); + + // Act + var violations = evaluator.DiscouragedNames(); + + // Assert + await Assert.That(violations).HasSingleItem(); + await Assert.That(violations[0].Rule).IsEqualTo(ArchitectureRules.DiscouragedName); + } + + [Test] + public async Task Waiver_GivenAMappedProject_ExcusesTheNameButKeepsTheDependencyRules() + { + // Arrange + // The point of a waiver: the name is excused, the direction is not. + var policy = Policy(Core, Persistence, Composition); + policy = new ArchitecturePolicy + { + Version = policy.Version, + Responsibilities = policy.Responsibilities, + DiscouragedProjectNames = policy.DiscouragedProjectNames, + Waivers = + [ + new PolicyWaiver + { + Project = "Infrastructure", + MapsTo = "Persistence", + Rules = [ArchitectureRules.Naming, ArchitectureRules.DiscouragedName], + Reason = "Holds one adapter and predates the catalogue; renaming churns the docs for no gain.", + }, + ], + }; + + var evaluator = Evaluator(policy, Project("Infrastructure", ["Composition"]), Project("Composition")); + + // Act + var naming = evaluator.Naming(); + var discouraged = evaluator.DiscouragedNames(); + var matrix = evaluator.DependencyMatrix(); + + // Assert + await Assert.That(naming).IsEmpty(); + await Assert.That(discouraged).IsEmpty(); + // Still classified as Persistence, so Persistence's rules bite. + await Assert.That(matrix).HasSingleItem(); + await Assert.That(matrix[0].Message).Contains("must not depend on Composition"); + } + + [Test] + public async Task WaiverIntegrity_GivenAWaiverForAMissingProject_ReportsIt() + { + // Arrange + var policy = new ArchitecturePolicy + { + Version = 1, + Responsibilities = [Core], + Waivers = + [ + new PolicyWaiver + { + Project = "Deleted", + Rules = [ArchitectureRules.Naming], + Reason = "A reason long enough to pass the schema's minimum length requirement.", + }, + ], + }; + + var evaluator = Evaluator(policy, Project("Core")); + + // Act + var violations = evaluator.WaiverIntegrity(); + + // Assert + await Assert.That(violations).IsNotEmpty(); + await Assert.That(violations[0].Message).Contains("hides the next one"); + } + + [Test] + public async Task EvaluateAll_GivenACleanSolution_ReportsNothing() + { + // Arrange + var evaluator = Evaluator( + Policy(Core, Application, Persistence, Composition), + Project("Core"), + Project("Application", ["Core"]), + Project("Persistence", ["Core", "Application"]), + Project("Composition", ["Core", "Application", "Persistence"]) + ); + + // Act + var violations = evaluator.EvaluateAll(); + + // Assert + await Assert.That(violations).IsEmpty().Because(ArchitecturePolicyEvaluator.Report(violations)); + } + + [Test] + public async Task TestProjects_GivenTheirReferences_AreNotGovernedByTheMatrix() + { + // Arrange + // A test project references everything by design; applying the matrix to it would make the + // rule set unusable. + var evaluator = Evaluator( + Policy(Core, Persistence), + Project("Core.UnitTests", ["Core", "Persistence"], isTest: true), + Project("Core"), + Project("Persistence") + ); + + // Act + var violations = evaluator.EvaluateAll(); + + // Assert + await Assert.That(violations).IsEmpty().Because(ArchitecturePolicyEvaluator.Report(violations)); + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/ArchitectureTesting.UnitTests.csproj b/src/tests/ArchitectureTesting.UnitTests/ArchitectureTesting.UnitTests.csproj new file mode 100644 index 0000000..74cf6d2 --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/ArchitectureTesting.UnitTests.csproj @@ -0,0 +1,11 @@ + + + Purview.Architecture + false + Purview.Architecture.Testing.UnitTests + + + + + + diff --git a/src/tests/ArchitectureTesting.UnitTests/GraphDiscoveryTests.cs b/src/tests/ArchitectureTesting.UnitTests/GraphDiscoveryTests.cs new file mode 100644 index 0000000..0a99e5f --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/GraphDiscoveryTests.cs @@ -0,0 +1,168 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// +/// Covers how the rule set finds a repository's projects, and what happens when it finds none. +/// +/// +/// Discovery is convention rather than configuration, which is what makes adoption two files - and +/// also what makes a wrong convention silent. Both failure modes here produced a fully green +/// architecture suite that checked nothing, so each one is pinned. +/// +public sealed class GraphDiscoveryTests +{ + static ArchitecturePolicy PolicyWith(params string[] sourceRoots) => + new() + { + Version = 1, + SourceRoots = sourceRoots, + Responsibilities = + [ + new Responsibility + { + Name = "Core", + Category = "domain", + Purpose = "The fundamental domain model and its rules.", + MayDependOn = [], + MustNotDependOn = [], + }, + ], + }; + + static string CreateRepository(string projectFolder) + { + var root = Path.Combine(Path.GetTempPath(), "PurviewGraphDiscovery", Guid.NewGuid().ToString("N")); + var directory = Path.Combine(root, projectFolder, "Core"); + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, "Core.csproj"), """"""); + + return root; + } + + [Test] + public async Task GraphDiscovery_GivenNoProjectsFound_FailsInsteadOfPassingVacuously() + { + // Arrange + // The repository keeps its projects in 'source', but the policy looks under 'src'. + var root = CreateRepository("source"); + var policy = PolicyWith("src"); + + // Act + var evaluator = new ArchitecturePolicyEvaluator(policy, ProjectGraph.Load(root, policy.SourceRoots)); + var violations = evaluator.EvaluateAll(); + + // Assert + // Without the graphDiscovery rule this is zero violations and a green suite. + await Assert.That(violations).IsNotEmpty(); + await Assert.That(violations[0].Rule).IsEqualTo(ArchitectureRules.GraphDiscovery); + await Assert.That(violations[0].Message).Contains("without checking anything"); + await Assert.That(violations[0].Message).Contains("sourceRoots"); + } + + [Test] + public async Task GraphDiscovery_GivenAConfiguredSourceRoot_FindsTheProjects() + { + // Arrange + var root = CreateRepository("source"); + var policy = PolicyWith("source"); + + // Act + var evaluator = new ArchitecturePolicyEvaluator(policy, ProjectGraph.Load(root, policy.SourceRoots)); + + // Assert + await Assert.That(evaluator.GraphDiscovery()).IsEmpty(); + await Assert.That(evaluator.ProductionProjects.Select(project => project.Name)).Contains("Core"); + } + + [Test] + public async Task ProjectGraph_GivenAnEmptySourceRootList_ScansTheRepositoryRoot() + { + // Arrange + // A flat layout, where projects sit directly under the repository root. + var root = CreateRepository("."); + + // Act + var projects = ProjectGraph.Load(root, []); + + // Assert + await Assert.That(projects.ContainsKey("Core")).IsTrue(); + } + + [Test] + public async Task ProjectGraph_GivenSeveralSourceRoots_ScansAllOfThem() + { + // Arrange + var root = CreateRepository("src"); + var extra = Path.Combine(root, "samples", "Sample"); + Directory.CreateDirectory(extra); + await File.WriteAllTextAsync( + Path.Combine(extra, "Sample.csproj"), + """""" + ); + + // Act + var projects = ProjectGraph.Load(root, ["src", "samples"]); + + // Assert + await Assert.That(projects.ContainsKey("Core")).IsTrue(); + await Assert.That(projects.ContainsKey("Sample")).IsTrue(); + } + + [Test] + public async Task ProjectGraph_GivenAMissingSourceRoot_SkipsItWithoutThrowing() + { + // Arrange + // A repository may configure roots it does not all use; that is not an error on its own. + var root = CreateRepository("src"); + + // Act + var projects = ProjectGraph.Load(root, ["src", "does-not-exist"]); + + // Assert + await Assert.That(projects.ContainsKey("Core")).IsTrue(); + } + + [Test] + public async Task RepositoryRoot_GivenNoPolicyInsideTheRepository_DoesNotEscapeIntoParentFolders() + { + // Arrange + // A policy in the folder *above* the repository, which the probe must never reach: it would + // silently validate this repository against a sibling checkout's rules. + var parent = Path.Combine(Path.GetTempPath(), "PurviewGraphDiscovery", Guid.NewGuid().ToString("N")); + var repository = Path.Combine(parent, "repo"); + Directory.CreateDirectory(Path.Combine(parent, ".config")); + Directory.CreateDirectory(Path.Combine(repository, ".git")); + var nested = Path.Combine(repository, "src", "tests", "X", "bin", "Debug", "net10.0"); + Directory.CreateDirectory(nested); + await File.WriteAllTextAsync(Path.Combine(parent, ".config", "architecture-policy.json"), "{}"); + + // Act + // Walking up from the test binary: no policy in the repository, then the .git boundary. + var searched = Probe(nested); + + // Assert + await Assert.That(searched).IsEqualTo(repository); + } + + /// + /// Mirrors 's walk, which anchors on + /// AppContext.BaseDirectory and so cannot be pointed at a fixture directly. + /// + /// The directory to start from. + /// The repository boundary the walk stopped at. + static string Probe(string start) + { + DirectoryInfo? directory = new(start); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, ArchitecturePolicy.RelativePath))) + return directory.FullName; + + if (Directory.Exists(Path.Combine(directory.FullName, ".git"))) + return directory.FullName; + + directory = directory.Parent; + } + + return string.Empty; + } +} diff --git a/src/tests/ArchitectureTesting.UnitTests/InheritedBaseClassTests.cs b/src/tests/ArchitectureTesting.UnitTests/InheritedBaseClassTests.cs new file mode 100644 index 0000000..1035b35 --- /dev/null +++ b/src/tests/ArchitectureTesting.UnitTests/InheritedBaseClassTests.cs @@ -0,0 +1,92 @@ +namespace Purview.Architecture.Testing.UnitTests; + +/// +/// Proves TUnit discovers the tests a consuming repository inherits from +/// . +/// +/// +/// The whole adoption story is "add a policy file and one derived class", so test discovery through +/// inheritance is load-bearing. If TUnit did not walk base types, a consumer would add the class, +/// see a green run, and be enforcing nothing at all - the worst possible failure for this package. +/// +/// It points at a fixture repository rather than this one: build-sdk's own projects (BuildSdk, +/// Analyzers, CodeFixers) are an MSBuild SDK and Roslyn components, which the responsibility +/// catalogue does not describe. +/// +[InheritsTests] +public sealed class InheritedBaseClassTests : PurviewArchitectureTests +{ + static readonly Lazy Fixture = new(CreateFixtureRepository); + + /// + protected override string? RepositoryRoot => Fixture.Value; + + /// + /// Writes a minimal, policy-conformant repository to a temporary folder. + /// + /// The fixture repository root. + static string CreateFixtureRepository() + { + var root = Path.Combine(Path.GetTempPath(), "PurviewArchitectureFixture", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(root, ".config")); + + File.WriteAllText( + Path.Combine(root, ".config", "architecture-policy.json"), + $$""" + { + "$schema": "{{PolicySchemaValidator.SchemaId}}", + "version": 1, + "responsibilities": [ + { + "name": "Core", + "aliases": ["Domain"], + "category": "domain", + "purpose": "The fundamental domain model, its invariants and its rules.", + "mayDependOn": [], + "mustNotDependOn": ["Application", "Persistence"] + }, + { + "name": "Application", + "category": "application", + "purpose": "Coordinates the application's use cases and workflows.", + "mayDependOn": ["Core"], + "mustNotDependOn": ["Persistence"] + }, + { + "name": "Persistence", + "category": "infrastructure", + "purpose": "Durable data access and persistence-specific configuration.", + "mayDependOn": ["Core", "Application"], + "mustNotDependOn": [] + } + ] + } + """ + ); + + WriteProject(root, "Core"); + WriteProject(root, "Application", "Core"); + WriteProject(root, "Persistence", "Core"); + + return root; + } + + /// Writes a project file with the given project references. + /// The fixture repository root. + /// The project name. + /// The projects it references. + static void WriteProject(string root, string name, params string[] references) + { + var directory = Path.Combine(root, "src", name); + Directory.CreateDirectory(directory); + + var items = string.Concat( + references.Select(reference => $"""""") + ); + + File.WriteAllText( + Path.Combine(directory, $"{name}.csproj"), + $"""{items}""" + ); + } +} diff --git a/src/tests/BuildSdk.IntegrationTests/AgentPackFolderTests.cs b/src/tests/BuildSdk.IntegrationTests/AgentPackFolderTests.cs deleted file mode 100644 index bb0d0e2..0000000 --- a/src/tests/BuildSdk.IntegrationTests/AgentPackFolderTests.cs +++ /dev/null @@ -1,514 +0,0 @@ -using System.IO.Compression; -using System.Text.Json; -using Purview.BuildSdk.Harness; -using Purview.BuildSdk.Infra; - -namespace Purview.BuildSdk; - -/// -/// Verifies the Sdk/.agents folder packaging workflow and PurviewAutoSdkPack behaviour. -/// -sealed class AgentPackFolderTests -{ - [Test] - public async Task PurviewAutoSdkPack_PacksPhysicalAndLinkedRootAssets(CancellationToken cancellationToken) - { - using var h = await ProjectHarness.CreateAsync( - "PackableProject", - extraProps: """ - - net8.0;net9.0 - true - true - true - README.md - MIT - purview-logo-light.png - """, - extraItems: """ - - """, - cancellationToken: cancellationToken - ); - - Directory.CreateDirectory(Path.Combine(h.ProjectDirectory, "Sdk")); - await File.WriteAllTextAsync( - Path.Combine(h.ProjectDirectory, "Sdk", "README.md"), - "# Package", - cancellationToken - ); - await File.WriteAllBytesAsync( - Path.Combine(h.SolutionDirectory, "purview-logo-light.png"), - [0xFF, 0xD8, 0xFF, 0xD9], - cancellationToken - ); - - var feedDirectory = Path.Combine(h.SolutionDirectory, "feed"); - var packageVersion = $"0.0.0-integration-test-{Guid.NewGuid():N}"; - var (exitCode, stdOut, stdErr) = await RunProcessAsync( - "dotnet", - $"pack \"{h.ProjectFilePath}\" -c Release -o \"{feedDirectory}\" -p:PackageVersion={packageVersion} -p:Version={packageVersion}", - h.SolutionDirectory, - cancellationToken - ); - - await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); - await Assert.That(stdOut + stdErr).DoesNotContain("NU5118"); - var packagePath = Directory.GetFiles(feedDirectory, "Test.PackableProject.*.nupkg").Single(); - using var package = await ZipFile.OpenReadAsync(packagePath, cancellationToken); - var entries = package.Entries.Select(entry => entry.FullName).ToList(); - await Assert.That(entries).Contains("README.md"); - await Assert.That(entries).Contains("purview-logo-light.png"); - } - - [Test] - public async Task PurviewAutoSdkPack_ExposesSdkDotAgentsInProjectTree(CancellationToken cancellationToken) - { - var sdkProjectPath = Path.GetFullPath(Path.Combine(SdkPaths.SdkDirectory, "..", "BuildSdk.csproj")); - - var (exitCode, stdOut, stdErr) = await RunProcessAsync( - "dotnet", - $"msbuild \"{sdkProjectPath}\" -nologo -noconlog -t:PrepareForBuild -getItem:None", - Path.GetDirectoryName(sdkProjectPath)!, - cancellationToken - ); - - await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); - - var jsonStart = stdOut.IndexOf('{', StringComparison.Ordinal); - await Assert.That(jsonStart >= 0).IsTrue(); - - using var doc = JsonDocument.Parse(stdOut[jsonStart..]); - var noneItems = doc.RootElement.GetProperty("Items").GetProperty("None").EnumerateArray(); - var expectedPath = Path.GetFullPath( - Path.Combine( - Path.GetDirectoryName(sdkProjectPath)!, - "Sdk", - ".agents", - "skills", - "sdk-configuration-reference", - "SKILL.md" - ) - ); - - var agentEntry = noneItems.FirstOrDefault(item => - string.Equals(item.GetProperty("FullPath").GetString(), expectedPath, StringComparison.OrdinalIgnoreCase) - ); - - await Assert.That(agentEntry.ValueKind).IsEqualTo(JsonValueKind.Object); - await Assert - .That(agentEntry.GetProperty("PackagePath").GetString()) - .IsEqualTo(".agents/skills/sdk-configuration-reference/SKILL.md"); - } - - [Test] - public async Task PurviewAutoSdkPack_PacksSdkDotAgentsSkillsIntoNuGetPackage(CancellationToken cancellationToken) - { - // Arrange - using var h = await ProjectHarness.CreateAsync( - "PackableProject", - extraProps: "truetruefalse", - cancellationToken: cancellationToken - ); - - await File.WriteAllTextAsync(Path.Combine(h.SolutionDirectory, ".git"), string.Empty, cancellationToken); - await File.WriteAllTextAsync( - Path.Combine(h.SolutionDirectory, "package.json"), - /*lang=json,strict*/ - """{"name": "packable-project", "version": "1.0.0"}""", - cancellationToken - ); - await File.WriteAllTextAsync( - Path.Combine(h.SolutionDirectory, "Directory.Packages.props"), - """ - - - true - - - - - - - - """, - cancellationToken - ); - - var agentPackSkillsDirectory = Path.Combine(h.ProjectDirectory, "Sdk", ".agents", "skills", "observability"); - Directory.CreateDirectory(agentPackSkillsDirectory); - await File.WriteAllTextAsync( - Path.Combine(agentPackSkillsDirectory, "SKILL.md"), - "# Observability\n", - cancellationToken - ); - - var feedDirectory = Path.Combine(h.SolutionDirectory, "feed"); - Directory.CreateDirectory(feedDirectory); - var packageVersion = $"0.0.0-integration-test-{Guid.NewGuid():N}"; - - // Act - var (exitCode, stdOut, stdErr) = await RunProcessAsync( - "dotnet", - $"pack \"{h.ProjectFilePath}\" -c Release -o \"{feedDirectory}\" -p:PackageVersion={packageVersion} -p:Version={packageVersion}", - h.SolutionDirectory, - cancellationToken - ); - - // Assert - await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); - - var packagePath = Directory - .GetFiles(feedDirectory, $"Test.PackableProject.{packageVersion}.nupkg", SearchOption.TopDirectoryOnly) - .SingleOrDefault(); - - await Assert.That(packagePath).IsNotNull().Because("The packed project package was not created."); - - using var zip = await ZipFile.OpenReadAsync(packagePath!, cancellationToken); - var entries = zip.Entries.Select(entry => entry.FullName).ToList(); - await Assert.That(entries).Contains(".agents/skills/observability/SKILL.md"); - await Assert - .That(entries) - .Contains(".agents/skills/observability/.gitignore") - .Because($"{stdOut}\n{stdErr}\n--- package entries ---\n{string.Join("\n", entries)}"); - - var gitIgnoreEntry = zip.Entries.Single(entry => entry.FullName == ".agents/skills/observability/.gitignore"); - using var gitIgnoreStream = await gitIgnoreEntry.OpenAsync(cancellationToken); - using var reader = new StreamReader(gitIgnoreStream); - var gitIgnoreContent = (await reader.ReadToEndAsync(cancellationToken)).ReplaceLineEndings("\n"); - await Assert - .That(gitIgnoreContent) - .IsEqualTo( - // The last entry ignores this file itself. Re-including it ('!.gitignore') left a - // mirrored, never-committed file permanently untracked in every consuming repository, - // and overrode the generated '.agents/.gitignore' because the nearest file wins. - "# Ignore all files\n*\n\n# Don't ignore directories, so Git can traverse them\n!*/\n\n# This file is mirrored from a NuGet package, so ignore it too\n/.gitignore" - ); - } - - [Test] - public async Task PurviewAutoSdkPack_PacksSdkDotAgentsContentOutsideSkillsIntoNuGetPackage( - CancellationToken cancellationToken - ) - { - // Arrange - using var h = await ProjectHarness.CreateAsync( - "PackableProject", - extraProps: "truetruefalse", - cancellationToken: cancellationToken - ); - - await File.WriteAllTextAsync(Path.Combine(h.SolutionDirectory, ".git"), string.Empty, cancellationToken); - await File.WriteAllTextAsync( - Path.Combine(h.SolutionDirectory, "package.json"), - /*lang=json,strict*/ - """{"name": "packable-project", "version": "1.0.0"}""", - cancellationToken - ); - await File.WriteAllTextAsync( - Path.Combine(h.SolutionDirectory, "Directory.Packages.props"), - """ - - - true - - - - - - - - """, - cancellationToken - ); - - var agentPackPromptsDirectory = Path.Combine(h.ProjectDirectory, "Sdk", ".agents", "prompts", "example"); - Directory.CreateDirectory(agentPackPromptsDirectory); - await File.WriteAllTextAsync( - Path.Combine(agentPackPromptsDirectory, "PROMPT.md"), - "# Prompt\n", - cancellationToken - ); - - var feedDirectory = Path.Combine(h.SolutionDirectory, "feed"); - Directory.CreateDirectory(feedDirectory); - var packageVersion = $"0.0.0-integration-test-{Guid.NewGuid():N}"; - - // Act - var (exitCode, stdOut, stdErr) = await RunProcessAsync( - "dotnet", - $"pack \"{h.ProjectFilePath}\" -c Release -o \"{feedDirectory}\" -p:PackageVersion={packageVersion} -p:Version={packageVersion}", - h.SolutionDirectory, - cancellationToken - ); - - // Assert - await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); - - var packagePath = Directory - .GetFiles(feedDirectory, $"Test.PackableProject.{packageVersion}.nupkg", SearchOption.TopDirectoryOnly) - .SingleOrDefault(); - - await Assert.That(packagePath).IsNotNull().Because("The packed project package was not created."); - - using var zip = await ZipFile.OpenReadAsync(packagePath!, cancellationToken); - var entries = zip.Entries.Select(entry => entry.FullName).ToList(); - await Assert.That(entries).Contains(".agents/prompts/example/PROMPT.md"); - await Assert - .That(entries) - .Contains(".agents/prompts/example/.gitignore") - .Because($"{stdOut}\n{stdErr}\n--- package entries ---\n{string.Join("\n", entries)}"); - } - - /// - /// A blanket '.gitignore' is packed only for a folder this SDK wholly owns - the two tests above - /// use nested folders ('skills/observability', 'prompts/example'), which is that case. It must - /// **not** be packed for a file sitting directly in '.agents/prompts' or '.agents/agents', because - /// in a consuming repository those folders hold the mirrored files beside the repository's own - /// authored prompts and agents: a blanket rule there would hide the author's work. Those files are - /// ignored per file by the consumer-side sync instead - see - /// RepositoryFileSyncTests.AgentFolderSync_IgnoresMirroredFilesPerFile_AndLeavesAuthoredFilesTracked. - /// - [Test] - public async Task PurviewAutoSdkPack_DoesNotPackABlanketGitIgnoreForASharedAgentFolder( - CancellationToken cancellationToken - ) - { - // Arrange - using var h = await ProjectHarness.CreateAsync( - "PackableProject", - extraProps: "truetruefalse", - cancellationToken: cancellationToken - ); - - await File.WriteAllTextAsync(Path.Combine(h.SolutionDirectory, ".git"), string.Empty, cancellationToken); - await File.WriteAllTextAsync( - Path.Combine(h.SolutionDirectory, "package.json"), - /*lang=json,strict*/ - """{"name": "packable-project", "version": "1.0.0"}""", - cancellationToken - ); - await File.WriteAllTextAsync( - Path.Combine(h.SolutionDirectory, "Directory.Packages.props"), - """ - - - true - - - - - - - - """, - cancellationToken - ); - - // Files directly inside the first-level folders, exactly as the SDK's own Sdk/.agents is laid out. - var promptsDirectory = Path.Combine(h.ProjectDirectory, "Sdk", ".agents", "prompts"); - Directory.CreateDirectory(promptsDirectory); - await File.WriteAllTextAsync(Path.Combine(promptsDirectory, "diagnose.md"), "# Diagnose\n", cancellationToken); - - var agentsDirectory = Path.Combine(h.ProjectDirectory, "Sdk", ".agents", "agents"); - Directory.CreateDirectory(agentsDirectory); - await File.WriteAllTextAsync(Path.Combine(agentsDirectory, "setup.md"), "# Setup\n", cancellationToken); - - var feedDirectory = Path.Combine(h.SolutionDirectory, "feed"); - Directory.CreateDirectory(feedDirectory); - var packageVersion = $"0.0.0-integration-test-{Guid.NewGuid():N}"; - - // Act - var (exitCode, stdOut, stdErr) = await RunProcessAsync( - "dotnet", - $"pack \"{h.ProjectFilePath}\" -c Release -o \"{feedDirectory}\" -p:PackageVersion={packageVersion} -p:Version={packageVersion}", - h.SolutionDirectory, - cancellationToken - ); - - // Assert - await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); - - var packagePath = Directory - .GetFiles(feedDirectory, $"Test.PackableProject.{packageVersion}.nupkg", SearchOption.TopDirectoryOnly) - .SingleOrDefault(); - - await Assert.That(packagePath).IsNotNull().Because("The packed project package was not created."); - - using var zip = await ZipFile.OpenReadAsync(packagePath!, cancellationToken); - var entries = zip.Entries.Select(entry => entry.FullName).ToList(); - var because = $"{stdOut}\n{stdErr}\n--- package entries ---\n{string.Join("\n", entries)}"; - - // The content itself still ships. - await Assert.That(entries).Contains(".agents/prompts/diagnose.md").Because(because); - await Assert.That(entries).Contains(".agents/agents/setup.md").Because(because); - - // ...but without a blanket ignore over a folder the consumer also authors into. - await Assert.That(entries).DoesNotContain(".agents/prompts/.gitignore").Because(because); - await Assert.That(entries).DoesNotContain(".agents/agents/.gitignore").Because(because); - } - - [Test] - public async Task PurviewAutoSdkPack_PacksAllSdkRootFoldersIntoNuGetPackage(CancellationToken cancellationToken) - { - // Arrange - using var h = await ProjectHarness.CreateAsync( - "PackableProject", - extraProps: "truetruefalse", - cancellationToken: cancellationToken - ); - - await File.WriteAllTextAsync(Path.Combine(h.SolutionDirectory, ".git"), string.Empty, cancellationToken); - await File.WriteAllTextAsync( - Path.Combine(h.SolutionDirectory, "package.json"), - /*lang=json,strict*/ - """{"name": "packable-project", "version": "1.0.0"}""", - cancellationToken - ); - await File.WriteAllTextAsync( - Path.Combine(h.SolutionDirectory, "Directory.Packages.props"), - """ - - - true - - - - - - - - """, - cancellationToken - ); - - var sdkAgentSkillsDirectory = Path.Combine(h.ProjectDirectory, "Sdk", ".agents", "skills", "test"); - Directory.CreateDirectory(sdkAgentSkillsDirectory); - await File.WriteAllTextAsync(Path.Combine(sdkAgentSkillsDirectory, "SKILL.md"), "# Test\n", cancellationToken); - - var sdkAgentAgentsDirectory = Path.Combine(h.ProjectDirectory, "Sdk", ".agents", "agents"); - Directory.CreateDirectory(sdkAgentAgentsDirectory); - await File.WriteAllTextAsync( - Path.Combine(sdkAgentAgentsDirectory, "this-is-a-test-agent.md"), - "# Test\n", - cancellationToken - ); - - var sdkAgentPromptsDirectory = Path.Combine(h.ProjectDirectory, "Sdk", ".agents", "prompts"); - Directory.CreateDirectory(sdkAgentPromptsDirectory); - await File.WriteAllTextAsync( - Path.Combine(sdkAgentPromptsDirectory, "this-is-a-test-prompt.md"), - "# Test\n", - cancellationToken - ); - - var sdkGitHubDirectory = Path.Combine(h.ProjectDirectory, "Sdk", ".github", "workflows"); - Directory.CreateDirectory(sdkGitHubDirectory); - await File.WriteAllTextAsync(Path.Combine(sdkGitHubDirectory, "ci.yml"), "name: CI\n", cancellationToken); - - var sdkBuildDirectory = Path.Combine(h.ProjectDirectory, "Sdk", "build"); - Directory.CreateDirectory(sdkBuildDirectory); - await File.WriteAllTextAsync( - Path.Combine(sdkBuildDirectory, "Custom.targets"), - "\n", - cancellationToken - ); - - var sdkBuildTransitiveDirectory = Path.Combine(h.ProjectDirectory, "Sdk", "buildTransitive"); - Directory.CreateDirectory(sdkBuildTransitiveDirectory); - await File.WriteAllTextAsync( - Path.Combine(sdkBuildTransitiveDirectory, "Custom.props"), - "\n", - cancellationToken - ); - - var sdkBuildMultiTargetingDirectory = Path.Combine(h.ProjectDirectory, "Sdk", "buildMultiTargeting"); - Directory.CreateDirectory(sdkBuildMultiTargetingDirectory); - await File.WriteAllTextAsync( - Path.Combine(sdkBuildMultiTargetingDirectory, "Custom.props"), - "\n", - cancellationToken - ); - - await File.WriteAllTextAsync( - Path.Combine(h.ProjectDirectory, "Sdk", "README.md"), - "# README\n", - cancellationToken - ); - await File.WriteAllTextAsync( - Path.Combine(h.ProjectDirectory, "Sdk", "logo.svg"), - "\n", - cancellationToken - ); - await File.WriteAllTextAsync( - Path.Combine(h.ProjectDirectory, "Sdk", "Custom.props"), - "\n", - cancellationToken - ); - - var feedDirectory = Path.Combine(h.SolutionDirectory, "feed"); - Directory.CreateDirectory(feedDirectory); - var packageVersion = $"0.0.0-integration-test-{Guid.NewGuid():N}"; - - // Act - var (exitCode, stdOut, stdErr) = await RunProcessAsync( - "dotnet", - $"pack \"{h.ProjectFilePath}\" -c Release -o \"{feedDirectory}\" -p:PackageVersion={packageVersion} -p:Version={packageVersion}", - h.SolutionDirectory, - cancellationToken - ); - - // Assert - await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); - - var packagePath = Directory - .GetFiles(feedDirectory, $"Test.PackableProject.{packageVersion}.nupkg", SearchOption.TopDirectoryOnly) - .SingleOrDefault(); - - await Assert.That(packagePath).IsNotNull().Because("The packed project package was not created."); - - using var zip = await ZipFile.OpenReadAsync(packagePath!, cancellationToken); - var entries = zip.Entries.Select(entry => entry.FullName).ToList(); - - await Assert.That(entries).Contains(".agents/skills/test/SKILL.md"); - await Assert.That(entries).Contains(".agents/skills/test/.gitignore"); - await Assert.That(entries).Contains(".agents/prompts/this-is-a-test-prompt.md"); - await Assert.That(entries).Contains(".agents/agents/this-is-a-test-agent.md"); - await Assert.That(entries).Contains(".github/workflows/ci.yml"); - await Assert.That(entries).Contains("build/Custom.targets"); - await Assert.That(entries).Contains("buildTransitive/Custom.props"); - await Assert.That(entries).Contains("buildMultiTargeting/Custom.props"); - await Assert.That(entries).Contains("README.md"); - await Assert.That(entries).Contains("logo.svg"); - await Assert.That(entries).Contains("Sdk/Custom.props"); - } - - static async Task<(int Code, string StdOut, string StdErr)> RunProcessAsync( - string fileName, - string arguments, - string workingDirectory, - CancellationToken cancellationToken - ) - { - using var process = new System.Diagnostics.Process - { - StartInfo = new System.Diagnostics.ProcessStartInfo - { - FileName = fileName, - Arguments = arguments, - WorkingDirectory = workingDirectory, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }, - }; - - process.Start(); - var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); - await process.WaitForExitAsync(cancellationToken); - - return (process.ExitCode, await stdoutTask, await stderrTask); - } -} diff --git a/src/tests/BuildSdk.IntegrationTests/BuildSdk.IntegrationTests.csproj b/src/tests/BuildSdk.IntegrationTests/BuildSdk.IntegrationTests.csproj index 8489af5..9e400b3 100644 --- a/src/tests/BuildSdk.IntegrationTests/BuildSdk.IntegrationTests.csproj +++ b/src/tests/BuildSdk.IntegrationTests/BuildSdk.IntegrationTests.csproj @@ -1,4 +1,13 @@ + + + + +