From cea00312ac63267f307e39926f1f06926b16a544 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sun, 15 Nov 2026 00:10:22 +0000 Subject: [PATCH 01/12] chore(skills): refresh installed agent skills Restored by the skills CLI: the updated purview-buildsdk-* and purview-sourcegenerator-* skills, the new purview-site-* and catalogue skills, and the matching skills-lock.json entries. Committed on its own, as AGENTS.md requires for .agents/skills/**. --- .agents/skills/add-catalogue-project/SKILL.md | 182 +++++ .../skills/audit-catalogue-projects/SKILL.md | 154 ++++ .../skills/docs-aggregation-rules/SKILL.md | 237 +++++++ .agents/skills/github-repo-metadata/SKILL.md | 89 +++ .../project-manifest-reference/SKILL.md | 150 ++++ .../skills/purview-buildsdk-adoption/SKILL.md | 95 ++- .../purview-buildsdk-configuration/SKILL.md | 122 +++- .../references/properties.md | 65 +- .../references/sourcelink.md | 80 +++ .../purview-buildsdk-docs-sync/SKILL.md | 145 ++++ .../references/truth-audit.md | 41 ++ .../references/wiki-structure.md | 109 +++ .../skills/purview-buildsdk-justfile/SKILL.md | 185 +++++ .../assets/just/bump-version.ts | 62 ++ .../assets/just/docs.just | 26 + .../assets/just/dotnet.just | 53 ++ .../assets/just/env.just | 56 ++ .../assets/just/index.just | 41 ++ .../assets/just/purview-build-nuget.just | 30 + .../assets/just/purview-build.just | 24 + .../assets/just/skills.just | 52 ++ .../assets/just/utilities.just | 44 ++ .../assets/just/version.just | 28 + .../references/skills-recipes.md | 100 +++ .../SKILL.md | 233 ++++++ .../references/schema-format.md | 87 +++ .../SKILL.md | 49 +- .../references/blazor.md | 27 +- .../references/detection.md | 19 +- .../purview-buildsdk-project-layout/SKILL.md | 13 +- .../SKILL.md | 26 +- .../SKILL.md | 229 ++++++ .../SKILL.md | 200 ++---- .../references/repository-root.md | 11 +- .../purview-buildsdk-skill-tests/SKILL.md | 5 +- .../references/authoring.md | 2 + .../skills/purview-buildsdk-testing/SKILL.md | 15 +- .../skills/purview-site-assessment/SKILL.md | 109 +++ .../references/rules.md | 79 +++ .../scripts/assess.mjs | 669 ++++++++++++++++++ .agents/skills/purview-site-docs/SKILL.md | 117 +++ .../purview-site-docs/references/diagrams.md | 174 +++++ .../purview-site-docs/references/layout.md | 92 +++ .../purview-site-docs/references/redirects.md | 49 ++ .../skills/purview-site-integration/SKILL.md | 89 +++ .../references/catalogue-contract.md | 93 +++ .../SKILL.md | 5 +- .../SKILL.md | 7 + .../purview-sourcegenerator-testing/SKILL.md | 2 +- .agents/skills/site-validation-loop/SKILL.md | 120 ++++ .agents/skills/use-case-authoring/SKILL.md | 84 +++ skills-lock.json | 106 ++- 52 files changed, 4623 insertions(+), 258 deletions(-) create mode 100644 .agents/skills/add-catalogue-project/SKILL.md create mode 100644 .agents/skills/audit-catalogue-projects/SKILL.md create mode 100644 .agents/skills/docs-aggregation-rules/SKILL.md create mode 100644 .agents/skills/github-repo-metadata/SKILL.md create mode 100644 .agents/skills/project-manifest-reference/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-configuration/references/sourcelink.md create mode 100644 .agents/skills/purview-buildsdk-docs-sync/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-docs-sync/references/truth-audit.md create mode 100644 .agents/skills/purview-buildsdk-docs-sync/references/wiki-structure.md create mode 100644 .agents/skills/purview-buildsdk-justfile/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-justfile/assets/just/bump-version.ts create mode 100644 .agents/skills/purview-buildsdk-justfile/assets/just/docs.just create mode 100644 .agents/skills/purview-buildsdk-justfile/assets/just/dotnet.just create mode 100644 .agents/skills/purview-buildsdk-justfile/assets/just/env.just create mode 100644 .agents/skills/purview-buildsdk-justfile/assets/just/index.just create mode 100644 .agents/skills/purview-buildsdk-justfile/assets/just/purview-build-nuget.just create mode 100644 .agents/skills/purview-buildsdk-justfile/assets/just/purview-build.just create mode 100644 .agents/skills/purview-buildsdk-justfile/assets/just/skills.just create mode 100644 .agents/skills/purview-buildsdk-justfile/assets/just/utilities.just create mode 100644 .agents/skills/purview-buildsdk-justfile/assets/just/version.just create mode 100644 .agents/skills/purview-buildsdk-justfile/references/skills-recipes.md create mode 100644 .agents/skills/purview-buildsdk-msbuild-properties/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-msbuild-properties/references/schema-format.md create mode 100644 .agents/skills/purview-buildsdk-settings-docs-sync/SKILL.md create mode 100644 .agents/skills/purview-site-assessment/SKILL.md create mode 100644 .agents/skills/purview-site-assessment/references/rules.md create mode 100644 .agents/skills/purview-site-assessment/scripts/assess.mjs create mode 100644 .agents/skills/purview-site-docs/SKILL.md create mode 100644 .agents/skills/purview-site-docs/references/diagrams.md create mode 100644 .agents/skills/purview-site-docs/references/layout.md create mode 100644 .agents/skills/purview-site-docs/references/redirects.md create mode 100644 .agents/skills/purview-site-integration/SKILL.md create mode 100644 .agents/skills/purview-site-integration/references/catalogue-contract.md create mode 100644 .agents/skills/site-validation-loop/SKILL.md create mode 100644 .agents/skills/use-case-authoring/SKILL.md diff --git a/.agents/skills/add-catalogue-project/SKILL.md b/.agents/skills/add-catalogue-project/SKILL.md new file mode 100644 index 00000000..963fe0b2 --- /dev/null +++ b/.agents/skills/add-catalogue-project/SKILL.md @@ -0,0 +1,182 @@ +--- +name: add-catalogue-project +description: Use when adding a repository to the Purview-Dev website catalogue — recon the repo, author the catalogue record (docs, packages, tags, use cases), integrate it across the catalogue/docs/releases surfaces, and prove it with the validation pipeline. +category: purview-dev-website +roles: + - catalogue + - docs + - integration +tags: + - projects-yml + - astro + - starlight + - documentation +--- + +# add-catalogue-project Skill + +Use this skill when a repository must be added to (or promoted within) the Purview-Dev website. + +Related: `add-project-intake`, `project-manifest-reference`, `docs-aggregation-rules`, +`use-case-authoring`, `github-repo-metadata`, `site-validation-loop`. + +## Step 0 — decide the target collection + +| Situation | Where it goes | +| --- | --- | +| Repository is owned by the `purview-dev` organisation | `projects:` | +| Project the organisation contributes to but does not own (for example `likec4/likec4`) | `externalProjects:` | + +Only `projects:` entries get a `/projects//` page, documentation aggregation, LLM bundles and +release data. `externalProjects:` entries are link-out cards on `/projects/` and must never point at +a `purview-dev` repository. + +## Step 1 — recon the repository (never guess) + +```shell +# Metadata: description, topics, archived, default branch, discussions, homepage +gh api repos// --jq '{description, topics, archived, default_branch, has_discussions, homepage}' + +# Docs layout: does docs/ exist, is it a mirror of the wiki, is there a _Sidebar.md? +gh api "repos///git/trees/main?recursive=1" --jq '.tree[].path' +gh api "repos///contents/README.md" -H 'Accept: application/vnd.github.raw' + +# Project conventions: build type, agent guidance, licensing +gh api "repos///contents/purview-build.json" -H 'Accept: application/vnd.github.raw' + +# Release channel and tag shape +gh api "repos///releases?per_page=10" --jq '.[] | {tag_name, prerelease, published_at}' + +# Packages: confirm each id exists and see its channel (replace the id) +curl.exe -s "https://api.nuget.org/v3-flatcontainer//index.json" +# npm packages (skip the NuGet index): the registry document carries versions + dist-tags +curl.exe -s "https://registry.npmjs.org/%2F" +``` + +Record, as evidence for the record you are about to write: where the docs live, the exact markdown +file that should be the landing page, the NuGet package ids, the release channel, and the repository +description/topics. + +## Step 2 — classify + +- `category`: exactly one of `application-framework`, `validation`, `observability`, + `source-generation`, `aspire`, `build-tooling`, `developer-tooling`. +- `status`: + - `archived` when the repository is archived (GitHub `archived: true`). + - `preview` when there is no stable release on the registry the project publishes to. + - `stable` only when a stable (non-prerelease) release exists and is recommended for use. +- `experimental: true` (a flag, not a status value) when the tooling is explicitly an experiment: + no compatibility promise, no production support guarantee. Keep `status` as the honest release + channel alongside it (`preview` for prerelease-only tooling). See ADR 0004; the project page and + every aggregated documentation page then carry an Experimental warning. +- `id`: lowercase `[a-z0-9-]` slug, normally the repository name. +- `name`: display name. **It must slugify to `id` when the project has `docs`** (so the display name + and the `/projects//` and `/docs//` URLs agree). +- `order`: pick an unused value (10–80 in tens is the existing convention). + +## Step 3 — author the record + +Add the entry as `src/src/data/projects/.yml` (see `project-manifest-reference` for every field): + +```yaml + - id: + name: + shortDescription: + description: >- + + origin: >- + + useWhen: >- + + avoidWhen: >- + + repository: purview-dev/ + category: + status: + order: + docs: + source: github-path | wiki | readme + path: docs # github-path only + rootPage: Home.md # optional; see docs-aggregation-rules + exclude: [ _Sidebar.md ] # only files the repository actually ships + targetFrameworks: [ net8.0, net9.0 ] + install: nuget | msbuild-sdk | dotnet-tool # NuGet packages only; omit for npm + packages: + - id: + registry: nuget | npm # optional; defaults to nuget (ADR 0012) + description: + primary: true + related: [ ] + acknowledgments: + - name: + url: https://github.com// + description: + useCases: + - audience: developer + title: + scenario: >- + + code: | + + language: csharp + outcome: >- + + evidence: >- + + docsPage: +``` + +Every non-archived project needs at least **one** use case with `code` + `language` and an +`evidence` fact (ADR 0002). See `use-case-authoring`. + +## Step 4 — fix the upstream metadata (this is where tags come from) + +- Set GitHub **topics** on the repository (they become the site's tag chips). See + `github-repo-metadata` for the exact command and the taxonomy guidance. +- The repository description must be genuinely descriptive: it backs the `shortDescription` + fallback and social/`og:` metadata. +- Leave `discussions` unset: the loader detects it from the repository (`hasDiscussions`). Set + `discussions: false` only to opt a repository out, or `true` to force the link. + +## Step 5 — integrate and verify + +```shell +just data-sync # aggregate docs + refresh release data (cache-first) +just check-projects # deterministic catalogue guard (part of `just validate`) +bun run typecheck && bun run test +just check-generated +just validate # full chain: format, lint, build, link crawl, dist tests +``` + +Confirm each surface actually lights up: + +1. `/projects/` shows the card with the right category/status/tags (tags come from topics). +2. `/projects//` renders description, use cases, install panel, packages, and related links. +3. `/docs//` exists with the expected page count and a sidebar topic named after the project. +4. `/projects//llms.txt` and `/projects//llms-full.txt` are emitted and linked from + `/llms.txt`. +5. `/use-cases/` lists the new use cases under the right audiences. +6. `/releases/` shows GitHub and registry versions (NuGet or npm) for the declared packages. +7. If a live example page exists at `src/src/pages/examples/.mdx`, `/projects//` links it as + **Live example** (the page is discovered, not declared). + +## Step 6 — report + +State: the record added, the recon evidence, the package ids and channels, the docs source and page +count, the checks run with their result, and any `upstream` follow-ups (for example: topics missing, +docs not yet in a `docs/` folder, no stable release yet). + +## Pitfalls + +- `docs.path` must be a **repository path** (`docs`, `docs/wiki`), never a URL. +- A repository whose wiki is mirrored into `docs/wiki` is plain Markdown: `Home.md` is the hub and + `_Sidebar.md` (the navigation) is the only file that has to be excluded. A wiki that keeps a + different first page keeps `rootPage`; only a page the repository does **not** ship needs excluding, + and a stale pattern (an `index.md` the upstream deleted) is a silent no-op rather than an error. + See `docs-aggregation-rules`. +- Aggregation reads branch `main`. If the repository's default branch is `master`/`develop`, docs + aggregation silently yields nothing — rename or report it. +- The `README.md`-as-index mode (`readme:`) publishes exactly one page and cannot be combined with + `rootPage`. +- Adding a project does not require editing tests: the manifest tests assert `>=` counts, not exact + membership. But keep their expectations true (for example the `>= 8` use-case coverage). diff --git a/.agents/skills/audit-catalogue-projects/SKILL.md b/.agents/skills/audit-catalogue-projects/SKILL.md new file mode 100644 index 00000000..90328d7f --- /dev/null +++ b/.agents/skills/audit-catalogue-projects/SKILL.md @@ -0,0 +1,154 @@ +--- +name: audit-catalogue-projects +description: Use when auditing or repairing the catalogue records under src/src/data/ — the invariant checklist for documentation, stability, metadata, tags, packages, relationships, use cases and ordering, plus the findings report format. +category: purview-dev-website +roles: + - catalogue + - audit +tags: + - projects-yml + - validation + - metadata +--- + +# audit-catalogue-projects Skill + +Use this skill to answer "is every project in the catalogue correct?" with evidence, and to repair +the unambiguous problems. + +Related: `project-manifest-reference` (fields and vocabularies), `docs-aggregation-rules`, +`use-case-authoring`, `github-repo-metadata`, `site-validation-loop`. + +## Method + +1. **Deterministic first.** Run `just check-projects` and `bun run test`. These already enforce the + structural invariants; start from their output rather than re-deriving it. +2. **Then the judgement checks** that no script can make: is the description truthful, is the + category right, is the stability honest, is the use-case evidence real. +3. **Gather live evidence per project** with `gh` and NuGet (commands in `github-repo-metadata`). +4. **Report before repairing.** Produce the findings table, then apply `blocker`/`drift` fixes and + re-run the guards. + +## Invariant checklist + +### Structure (enforced by `loadProjects()` / `just check-projects`) + +- `id` is a lowercase `[a-z0-9-]` slug; `repository` is `purview-dev/` for `projects:`. +- `related`, `supersedes`, `supersededBy` reference known ids; a superseded project names its + successor and vice versa where the pairing exists. +- Every NuGet package id appears exactly once across the catalogue. +- `order` values are unique integers and the sorted order is the intended presentation order. +- For every project with `docs`, `name` slugifies to exactly `id`. +- Every non-archived project has ≥1 use case; every use case with `code` has `language`. +- Every `docsPage` resolves to a page in `src/.cache/docs/index.json` for that project. +- `externalProjects` never point at a `purview-dev` repository and always have a working `url`. + +### Documentation + +- `docs.source` matches reality: `github-path` when the repository has a docs folder, + `wiki` when the content lives in the wiki, `readme` only for single-page projects. +- `docs.path` exists **on branch `main`** and contains `.md` files. +- `rootPage` names a page that exists under `docs.path`; when it names a non-index page and a + conventional root page (`index.md`/`Home.md`/`readme.md`) also exists, that conventional page must be + listed in `exclude`. +- `exclude` keeps the navigation files out of the page list: `_Sidebar.md` (and `_Footer.md` for a + `wiki` source). It is a filter over the files the repository ships, so a pattern naming a file that + no longer exists is a no-op, not a warning or a failure. +- The aggregated page count is non-zero and the landing page is the page a reader should land on. +- Archived projects that still declare `docs` are intentional (they are excluded from the portal). +- Documentation is not duplicated here: this repository only holds the mirror. +- Pages moved in the repository's history have a redirect — in its `_redirects.yml` or the entry's + `docs.redirects` (ADR 0013). Diagrams are LikeC4/Mermaid pairs, not ASCII art or Mermaid in a + `text` fence. The consumer assessment script reports both against a clone (below); file the + repository-side fixes as `upstream` items, since they belong in the product repository. + +### Stability + +- `archived` repository ⇒ `status: archived`. +- No stable NuGet release ⇒ not `stable` (`preview`). +- `experimental: true` is orthogonal to `status`: report `experimental` with `status: stable` as + `drift` (the flag says "no support promise" while the channel says "recommended for use"), and + `experimental` on an archived project as a `blocker` (the schema rejects it outright). +- `stable` ⇒ a stable, listed NuGet release exists and the repository is not archived. +- Deprecated NuGet packages are reported, not silently accepted. +- A project with `packages: []` is intentional: build tooling consumed as a tool/SDK still declares + its package id when one exists. + +### Metadata and tags + +- Repository description is present, factual, and consistent with `shortDescription`/`description`. +- **Topics are non-empty** — they are the only source of site tags — and follow the lowercase, + hyphenated taxonomy. +- `discussions: true` only when the repository has discussions enabled. +- `homepage`/`url` targets resolve. +- `featured` is used deliberately (a handful of headline projects, not most of the catalogue). + +### Packages, frameworks and install + +- Each declared package id exists on NuGet and has published versions (the guard checks the release + cache). +- Exactly one `primary` when a project has multiple packages. +- `targetFrameworks` match the package's actual TFMs (`netstandard2.0` for generators/analyzers is + normal). `just check-projects` reports a mismatch as an advisory observation: project-level + frameworks against the union of the runtime packages' published TFMs, package-level frameworks + against that package's own. `netstandard*` is treated as an analyzer/generator target, not a + consumer framework. +- `install` is set only when the package is not a plain NuGet reference: `msbuild-sdk` for + `Purview.BuildSdk`, `dotnet-tool` for `Purview.Build`. + +### Use cases + +- Coverage across audiences is meaningful for the project's shape (adopter, approver, extender). +- `scenario` describes a situation; `outcome` describes the result; `evidence` is a fact. +- Snippets are short, compile-plausible, and use the right `language`. +- `docsPage` points at the page that actually carries the depth. + +## Evidence commands + +```shell +# Deterministic guards +just check-projects +bun run test + +# Per-project live evidence +gh api repos/purview-dev/ --jq '{description, topics, archived, default_branch, has_discussions, homepage}' +gh api "repos/purview-dev//git/trees/main?recursive=1" --jq '[.tree[].path | select(endswith(".md"))]' +gh api "repos/purview-dev//releases?per_page=10" --jq '.[] | {tag_name, prerelease}' +curl.exe -s "https://api.nuget.org/v3-flatcontainer//index.json" + +# Repository-side integration (docs layout, diagrams, redirects, skills) against a fresh clone +git clone --depth 300 https://github.com/purview-dev/.git / +node skills/purview-site-assessment/scripts/assess.mjs / --catalogue src/src/data/projects/.yml + +# Aggregated result +node -e "const m=require('./src/.cache/docs/index.json');for(const p of m.projects)console.log(p.projectId,p.pages.length)" +``` + +If `.cache/docs/index.json` or the release cache is stale, run `just data-sync` (live) first so the +evidence and the guards agree. + +## Report format + +```markdown +| Project | Field | Observed | Expected | Severity | Evidence | Fix | +| --- | --- | --- | --- | --- | --- | --- | +| example-tool | status | stable | preview | drift | no stable NuGet version (flat container: only 1.2.0-prerelease.3) | set status: preview | +| example-tool | — | topics: [] | 6–15 topics | drift | `gh api repos/...` shows an empty topics array | set repository topics | +``` + +- Severity: `blocker` (schema/build failure or a broken page), `drift` (data contradicts reality), + `polish` (quality), `upstream` (must be fixed in the product repository: missing docs, missing + topics you cannot set, no stable release). +- Close the report with: number of projects audited, checks run, fixes applied, and the items left + for a human. +- Apply only `blocker` and `drift` fixes; never restructure a record or change a category to make a + check pass. + +## Guardrails + +- Do not add use cases or evidence you cannot trace to the repository or the aggregated docs; report + the gap instead. +- Do not change the schema vocabulary; adding a `status`/`category` value needs an ADR. +- Do not hand-edit the docs mirror or caches to make the audit pass. +- If an audit fix changes a catalogue record, re-run `just check-projects`, `bun run test`, and + `just validate` before reporting completion. diff --git a/.agents/skills/docs-aggregation-rules/SKILL.md b/.agents/skills/docs-aggregation-rules/SKILL.md new file mode 100644 index 00000000..b5e646c8 --- /dev/null +++ b/.agents/skills/docs-aggregation-rules/SKILL.md @@ -0,0 +1,237 @@ +--- +name: docs-aggregation-rules +description: How the website aggregates documentation from product repositories into /docs// — sources, root-page and exclusion rules, ordering, injected front matter, tags from repository topics, staleness, and the failure modes with their remediations. +category: purview-dev-website +roles: + - docs + - reference +tags: + - starlight + - documentation + - aggregation +--- + +# docs-aggregation-rules Skill + +`just data-sync` runs `src/scripts/sync-docs.ts` → `aggregateDocs()` in +`src/src/lib/docs/aggregate.ts`. It reads each catalogue project's `docs` configuration, fetches +the markdown from the product repository, writes the mirror to `src/src/content/docs/docs//` +and records the page list in `src/.cache/docs/index.json`. + +**Never edit the mirror by hand** — it is gitignored and regenerated. + +## Where content comes from + +| `docs.source` | Fetch mechanism | Notes | +| --- | --- | --- | +| `github-path` | Repository tree (`git/trees/?recursive=1`) filtered to `/**/*.md` | Each file fetched from `raw.githubusercontent.com` at the channel's ref (the repository's default branch when no `channels` are declared) | +| `wiki` | `git clone --depth 1 https://github.com/.wiki.git` | `_Sidebar.md` and `_Footer.md` are never rendered. A wiki has one HEAD, so it supports a single channel | +| `readme` | Raw `README.md` | Exactly one page (`index`), single channel | + +The ref is the repository's **default branch** (from the release cache, falling +back to `main`) unless the manifest declares `docs.channels` (ADR 0009). +`lastReviewed` is the last commit date for the file (Atom feed for +`github-path`/`readme`, wiki HEAD commit for `wiki`); a channel pinned to a +release tag uses that release's date instead, because the content cannot move. + +## Channels (`live` and `prerelease`) + +A project may publish two views of its documentation: + +```yaml + docs: + source: github-path + path: docs/wiki + rootPage: Getting-Started.md + channels: + live: + ref: latest-stable + prerelease: + ref: default +``` + +- The **default** channel (`live`, or the only declared channel) is written to + `src/src/content/docs/docs//.md` and served at `/docs///`. +- Any other channel is written to + `src/src/content/docs/docs///.md` and served at + `/docs////`. +- `docs//` is reserved: a source page whose slug equals a channel id + raises `DocsValidationError`. +- `src/.cache/docs/index.json` is schema 2 and stores + `projects[].channels[] = { id, ref, refKind, version, label, default, pages[] }`. + A cache from an older schema reads as absent, so `just data-sync` regenerates it. +- The Starlight sidebar groups a two-channel project per channel (`Live 2.4.2`, + `Prerelease main`); a one-channel project keeps a flat list. The page title + component renders the switcher and a prerelease notice. +- **Promotion is automatic.** The prerelease channel is only aggregated while a + prerelease version outranks the newest stable release, computed from GitHub + release tags and NuGet versions. A stable release that catches up retires the + channel on the next sync (a `docs : prerelease documentation is not + published: …` line, not an error) and the next prerelease restores it. Never + remove the declaration just to stop publishing it. +- **Retired URLs redirect to live.** For each withheld channel URL the build + writes a `noindex` meta-refresh page (canonical + `data-pagefind-ignore`) + pointing at the live page with the same slug — GitHub Pages serves files only, + so `_redirects` is not an option. These files are written at + `astro:build:done` (`config/docs-redirects-integration.ts`), outside Astro's + route list, so they stay out of the sitemap, `discover.json` and the llms + bundles. +- Per-project `llms-full.txt` publishes the default channel only; `llms.txt` + also links the prerelease channel when one exists. + + +## Root page selection (the most common onboarding failure) + +1. A conventional index file wins by default: `index.md`, `Home.md`, or `readme.md` (case-insensitive + basename). +2. `docs.rootPage` may override it **only** when that conventional file is removed with + `docs.exclude`. Otherwise aggregation raises `DocsValidationError`: + *"selects a non-index page while a conventional root page already exists"*. +3. If no index file and no `rootPage` exist, aggregation raises + *"docs: no root page exists"* — add `index.md`/`Home.md`/`readme.md` or set `rootPage`. +4. If `rootPage` names a file that does not exist, aggregation raises + *"docs.rootPage: ... does not exist"*. + +The product wikis are mirrored into `docs/wiki` and are **plain Markdown**: `Home.md` is the hub, +`_Sidebar.md` is the whole navigation, and the repository carries no site configuration at all — the +engineering playbook deleted its `mkdocs.yml` and its TechDocs `index.md`, and no repository needs to +add them back. A repository that still ships a site file (`mkdocs.yml`) or a generated `index.md` is a +**deviation this portal ignores**: aggregation reads `docs.path` and this entry's `exclude`, never the +site configuration. So the pattern for a wiki is: + +```yaml + docs: + source: github-path + path: docs/wiki + rootPage: Home.md + exclude: [ _Sidebar.md ] +``` + +`Home.md` is itself a conventional root page, so `rootPage` restates the default rather than +overriding it, and nothing has to be excluded for it to win. Exclude only the files the repository +actually ships. + +While a repository still carries a site file, `just data-sync` reports it once and **never fails**: + + docs containers: mkdocs.yml is a repository site configuration and the portal ignores it — the + catalogue entry alone decides what is published, so the repository can drop the file. + +That is an advisory (`findLegacySiteConfig` in `src/lib/docs/aggregate.ts`), not a gate, and it is +printed on stdout so it survives into the build log; on GitHub Actions the same text is repeated as a +`::warning` annotation on the run. The aggregation keeps stripping the Material markup such a file +stands for (`./mkdocs`), so the file can be deleted upstream in its own time and on its own pull +request; when it is gone the advisory simply stops appearing. + +## Exclusions and ordering + +- `exclude` matches the **base file name** (`index.md`, `_Sidebar.md`) with `*` wildcards; it is + applied **before** the root page is selected (that is what makes rule 2 work). +- A pattern that matches nothing is a **no-op**: excluding `index.md` from a wiki that has since + replaced it with `Home.md` neither warns nor fails, so a stale pattern cannot block the build while + the upstream repository is mid-reorganisation. Remove it when you notice, but it is not drift the + guard reports. +- Page order comes from, in priority: `docs.order` (explicit slug list) → a `_Sidebar.md` link order + (both for `wiki` and for a mirrored wiki under `github-path`) → `1000`. +- The landing page is always `order: -1`, so it heads the sidebar as "Overview". +- A page's slug is its path from the docs root (ADR 0011): a flat corpus keeps the single-segment + slug (`Getting-Started.md` → `getting-started`, served at `/docs//getting-started/`), and a + topic folder stays a path segment (`schemas/Custom-Rules.md` → + `/docs//schemas/custom-rules/`). A nested `README.md` is the folder's index, so + `guide/README.md` publishes at `/docs//guide/` (the root `README.md` stays the landing page). + A `docsPage` in a use case must use a slug that still resolves in the **default** channel, which + is why it stays a single segment. + +## Injected front matter (per page) + +`title` (first `# H1`, else the slug), `description` (first prose paragraph, ≤160 chars), +`owners: [purview-dev]`, `status` (the project's status), `experimental: true` (written only when +the catalogue flags the project, ADR 0004), `lastReviewed`, `sourceProject`, +`sourceRepo`, `projectName`, `sourcePath`, `editUrl`, `tags` (the repository's GitHub **topics**, +absent when there are none), and `sidebar.label`/`sidebar.order`. + +Additional transformations: GitHub alert blockquotes become Starlight asides; relative links between +documents are rewritten to portal URLs; **relative** images are rewritten to GitHub raw URLs while +**absolute** image URLs pass through untouched — a registry badge such as +`![Purview.BuildSdk](https://img.shields.io/nuget/v/Purview.BuildSdk)` keeps its own URL, so a page can +show a live version without stating a number that rots; the first top-level H1 is removed from the body +(it becomes the front-matter `title`, which the page header renders) and any later H1 is demoted to `##`, +so each page shows its title once. + +## What the site does with it + +- Each documented project becomes a `starlight-sidebar-topics` topic whose items come from + `src/.cache/docs/index.json` (so the mirror and the manifest must come from the same sync). +- The sidebar mirrors the published tree (ADR 0011): every folder in a slug becomes a nested + `starlight` group labelled with the folder's name spelled out (`schemas` → `Schemas`), placed where + its first page appears and holding all of that folder's pages in channel order. Nesting is + recursive, a folder's `index.md` or nested `README.md` links the folder's own address, and a flat + corpus renders exactly the flat list it always did (`buildChannelItems` in + `src/lib/docs/sidebar.ts`). +- Each aggregated page also names the folders above it, above the title, in the same spelling as the + sidebar (`PageTitle.astro` + `docSectionTrail`). +- Archived projects are excluded from the docs portal, the sidebar topics, and per-project LLM + bundles, even if they declare `docs`. +- Every documented project gets `/projects//llms.txt` and `/projects//llms-full.txt`, linked + from `/llms.txt` and from the project page. +- Staleness: a page is flagged when `lastReviewed` is older than 365 days + (`src/src/lib/docs/staleness.ts`). +- A ```` ```likec4 ```` fence in a mirrored page is rendered when it compiles, and + stays **source** when it does not: the whole `docs` collection is relaxed through + astroc4's `validation: { collections: ['docs'] }` (`astro.config.ts`). Such a page + was written for another renderer, so its fence is often a partial snippet; astroc4 + logs a warning and leaves it as a highlighted code block instead of failing the + build. Pages the site authors (`src/pages/**`) are not in the collection, so they + keep strict validation. + +## Diagram pairs (ADR 0013) + +A product page pairs a GitHub rendering with a LikeC4 view: + +````markdown + +```mermaid +flowchart LR + resume --> next +``` +[LikeC4 model](./diagrams/the-loop.c4) + +```` + +`buildPage` runs `applyDiagramBlocks` (`src/lib/docs/diagrams.ts`) on the raw source before anything +else: the `.c4` file is read from the repository at the channel's ref (resolved relative to the page, +or `/`-rooted at the repository root), and the whole block becomes a ```` ```likec4 ```` fence holding +the model, with `view`, `project`, `title`, `description`, `height`, `collapsible` and `defaultOpen` +passed through. A missing model, an undeclared view or a malformed marker keeps the Mermaid and logs +`docs : line : …`. Markers inside code fences are ignored. + +## Repository redirects (`_redirects.yml`, ADR 0013) + +`/_redirects.yml` (the wiki root for `source: wiki`) holds `previous: current` pairs, either +under `redirects:` or as a bare map; each side is a slug or a Markdown path, reduced with `docSlug`. +It is read per channel and stored as `channels[].redirects` in `src/.cache/docs/index.json`; +`docsPageRedirects(projects, manifest)` writes each channel's pairs from that channel's base, after the +catalogue's `docs.redirects` (which wins on a conflict). An invalid file is a `DocsValidationError`; a +destination that is not a page, or a source that still is, is an advisory line in the sync log. + +## Consumer skills + +`skills/` at the repository root is what product repositories install +(`npx skills add purview-dev/purview-dev`): `purview-site-integration`, `purview-site-docs` and +`purview-site-assessment` (with `scripts/assess.mjs`). They restate these rules for authors, so a change +to slugs, diagram pairs or redirects here changes them in the same pull request. + +## Verifying + +```shell +just data-sync # expect "docs : pages" per project +node -e "console.log(require('./src/.cache/docs/index.json').projects)" +just build && just check-generated # asserts the mirror matches the manifest +``` + +A reorganised tree is verified in the built output: the old flat slug must be a meta-refresh page +pointing at the new address, on every channel that publishes the tree (`/docs///` and +`/docs//prerelease//`), and the sidebar of a nested channel must contain a `group-label`. + +A page that shows a registry badge instead of a version is verified the same way: the badge URL must +appear verbatim in the built page (`dist/docs///index.html`), not rewritten to a +`raw.githubusercontent.com` address. `docs.test.ts` covers an absolute badge URL beside a relative one. diff --git a/.agents/skills/github-repo-metadata/SKILL.md b/.agents/skills/github-repo-metadata/SKILL.md new file mode 100644 index 00000000..c346bc19 --- /dev/null +++ b/.agents/skills/github-repo-metadata/SKILL.md @@ -0,0 +1,89 @@ +--- +name: github-repo-metadata +description: How GitHub repository metadata feeds the website (topics become tags, description backs shortDescription, archived/description/discussions drive status and links) and how to inspect and correct it with gh. +category: purview-dev-website +roles: + - catalogue + - metadata +tags: + - github + - topics + - tags + - nuget +--- + +# github-repo-metadata Skill + +The manifest is curated, but several site surfaces read **live repository metadata** that is fetched +at build time into `src/.cache/releases/releases.json` (`fetchGitHubRepo`, `fetchNuGetIndex`, +`fetchNuGetSearch` in `src/src/lib/releases/github.ts`). NuGet **version numbers** are the one +exception: the browser re-checks them against the flat-container index and stores the result +(ADR 0008, `src/src/lib/releases/live.ts`), so a published version appears without a redeploy. +Everything else in the table below is build-time. + +## What comes from where + +| Site surface | Source | Notes | +| --- | --- | --- | +| Tag chips (cards, docs portal, docs-page front matter `tags`) | GitHub **topics** | `projectRepoEnrichment()` in `src/src/lib/releases/repo.ts` | +| `shortDescription` fallback | GitHub **description** | Used only when the manifest value is empty | +| "supplemental" description on the project page | GitHub description | Shown when it differs from the manifest description | +| Successor/archived messaging | GitHub **archived** flag + `status`/`supersededBy` | | +| Version numbers, release dates, badges | GitHub releases + NuGet flat-container index | | +| Downloads / deprecated / listed | NuGet search entry | Rendered on the release tables | +| Discussions link | repository discussion settings (detected) | `hasDiscussions` from the cache; a record's `discussions` is only an override | + +`search` corpora on `/projects/` deliberately include the topics, so missing topics make a project +undiscoverable by its domain keywords. + +## Inspecting + +```shell +gh api repos/purview-dev/ --jq '{description, topics, archived, default_branch, has_discussions, homepage, visibility}' +gh api "repos/purview-dev//releases?per_page=10" --jq '.[] | {tag_name, prerelease, draft}' +curl.exe -s "https://api.nuget.org/v3-flatcontainer//index.json" +curl.exe -s "https://azuresearch-usnc.nuget.org/query?q=packageid:&take=1" +``` + +## Correcting + +Topics are replaced wholesale (PUT), so send the complete set: + +```shell +echo '{"names":["csharp","dotnet","roslyn","source-generators","telemetry"]}' > topics.json +gh api repos/purview-dev//topics --method PUT --input topics.json +rm topics.json +``` + +`gh` also accepts repeated array fields: `gh api repos///topics --method PUT -f 'names[]=csharp' -f 'names[]=dotnet'`. + +Other metadata (`description`, `homepage`, `has_discussions`) is set in the repository settings or +with `gh api repos// --method PATCH -f description='...' -f homepage='...'`. Enabling +Discussions on an organisation repository requires organisation permission; report it as an +`upstream` item when you cannot do it. + +## Tag taxonomy guidance + +- Lowercase, hyphenated, and specific: `event-sourcing`, `source-generators`, `zero-allocation`, + `msbuild-sdk`, `dotnet-aspire`, `csharp`, `roslyn`. +- 6–15 topics is the useful range: enough to describe the domain, few enough that the card's first + four chips are meaningful (cards render `tags.slice(0, 4)`). +- Prefer ecosystem-standard names (`dotnet`, `csharp`, `nuget`, `opentelemetry`) so cross-repo + searches behave predictably. +- Topics are the **only** way tags reach the site. There is no `tags` field in a catalogue record — do + not add one. + +## Stability signals to check against `status` + +| Signal | Implication | +| --- | --- | +| `archived: true` | `status` must be `archived` (and `supersededBy` when a successor exists) | +| No stable release, only `-prerelease.N` tags | `preview` at most | +| Latest NuGet version is stable and listed | `stable` is allowed | +| The README/repository presents the project as an experiment | Set `experimental: true` (it does not replace `status`, which stays the release channel) | +| NuGet `deprecated: true` | Report as `drift`/`upstream`; a deprecated package should not front a `stable` project without explanation | +| `default_branch` is not `main` | Docs aggregation produces nothing — a blocker for any project with `docs` | + +Note that the organisation's Changesets automation publishes `vX.Y.Z-prerelease.N` tags **without** +setting GitHub's `prerelease` flag, so version selection is semver-based; do not judge the channel +from the GitHub `prerelease` boolean alone. diff --git a/.agents/skills/project-manifest-reference/SKILL.md b/.agents/skills/project-manifest-reference/SKILL.md new file mode 100644 index 00000000..d02ca06c --- /dev/null +++ b/.agents/skills/project-manifest-reference/SKILL.md @@ -0,0 +1,150 @@ +--- +name: project-manifest-reference +description: Field-by-field reference for the catalogue records under src/src/data/projects/ (schema vocabulary, defaults, loader-enforced rules, and the invariants the site build depends on). Use whenever authoring or reviewing a catalogue record. +category: purview-dev-website +roles: + - catalogue + - reference +tags: + - projects-yml + - zod + - schema +--- + +# project-manifest-reference Skill + +The catalogue is one file per project under `src/src/data/projects/` (plus +`src/src/data/external-projects.yml` for collaborations), typed by `src/src/lib/manifest/schema.ts` and validated +by `src/src/lib/manifest/load.ts`. `$schema: ../lib/manifest/schema.ts` is the first line of the file. + +## Vocabularies (do not invent values) + +| Field | Allowed values | +| --- | --- | +| `category` | `application-framework`, `validation`, `observability`, `source-generation`, `aspire`, `build-tooling`, `developer-tooling` | +| `status` | `stable`, `preview`, `archived` | +| `experimental` | boolean flag, default `false` — orthogonal to `status` (ADR 0004); invalid with `status: archived` | +| `install` | `nuget` (default), `msbuild-sdk`, `dotnet-tool` — NuGet packages only | +| `packages[].registry` | `nuget` (default), `npm` (ADR 0012) | +| `useCases[].audience` | `developer`, `team-lead`, `architect`, `contributor` (labels: Developer, Team lead, Architect, Contributor) | + +Adding a value to any of these lists is a schema change and requires an ADR. `experimental` is a +**flag, not a status value**: it never replaces `status`, which stays the project's release channel. + +## `projects[]` fields + +| Field | Required | Notes | +| --- | --- | --- | +| `id` | yes | Lowercase `[a-z0-9-]` slug; also the URL segment and the LLM bundle name | +| `name` | yes | Display name; **must slugify to `id` when `docs` is set**; must not start with `Purview ` | +| `shortDescription` | yes | One sentence; falls back to the GitHub description if empty, then to `name` | +| `description` | yes | Long description (2–4 sentences) | +| `origin` | yes | Why the project became reusable tooling | +| `useWhen` | yes | When it is the right choice | +| `avoidWhen` | yes | When it is the wrong choice | +| `repository` | yes | `owner/repository`; **`owner` must be `purview-dev`** for `projects[]` | +| `category` | yes | See vocabulary | +| `status` | yes | See vocabulary | +| `experimental` | no | Default `false`; marks an exploratory project (unstable API, no support promise). Orthogonal to `status`; invalid with `status: archived`. Displayed as its own status via `displayStatus()` | +| `featured` | no | Default `false`; shown prominently on the home page | +| `order` | no | Default `1000`; projects are sorted ascending; keep values unique | +| `docs` | no | See below | +| `install` | no | Default `nuget`; how a **NuGet** package is consumed, so it has no meaning for an npm package | +| `targetFrameworks` | no | e.g. `net8.0`, `net9.0`, `net10.0`, `netstandard2.0`; `just check-projects` reports (advisory) when it does not match the union of the runtime packages' published NuGet TFMs | +| `packages` | no | `{ id, registry?, description?, primary?, targetFrameworks? }[]`; defaults to `[]`. `registry` is `nuget` (default) or `npm`; versions come from that registry and the install panel follows it | +| `skills` | no | `{ name, path?, description? }[]` — Agent Skills the project publishes from `skills//SKILL.md` (ADR 0010); mirrored to `/skills///` and rendered with the shared install block; defaults to `[]` | +| `assets` | no | `{ path, output, description? }[]`; repository files mirrored into `public/` at build time (`path` is repo-relative, `output` is site-relative); defaults to `[]` | +| `related` | no | Known project ids; rendered in the project page aside | +| `useCases` | no | Default `[]`; at least one required for non-archived projects | +| `acknowledgments` | no | `{ name, url, description? }[]`; upstream work the project credits | +| `supersedes` / `supersededBy` | no | Known project ids; used for archived/successor messaging | +| `discussions` | no | **Detected, not authored**: the page links repository Discussions when the repository has them enabled (`hasDiscussions` in the release cache). Set it only to override — `false` opts a repository out | + +## `docs` (a discriminated union on `source`) + +| `source` | Fields | Behaviour | +| --- | --- | --- | +| `github-path` | `path` (required), plus `rootPage`, `readmeAsIndex`, `order`, `exclude`, `channels`, `redirects` | Walks `/**/*.md` at each channel's ref (default branch when no `channels`) | +| `wiki` | `rootPage`, `readmeAsIndex`, `order`, `exclude` | Shallow-clones `.wiki.git`; `_Sidebar.md` orders pages, `_Footer.md` is skipped. Single channel only | +| `readme` | none | Publishes `README.md` alone as `/docs//`. Single channel only | + +- `rootPage` names the markdown file that becomes the landing page (`index`). It may only select a + non-index page when the conventional root page is removed via `exclude` (the aggregation otherwise + raises `DocsValidationError`). A product wiki mirrored from `docs/wiki` uses `rootPage: Home.md`: the + wiki is plain Markdown with `Home.md` as its hub and `_Sidebar.md` as the whole navigation, and it + carries no site configuration — a repository that still ships one is a deviation the portal ignores, + not a requirement. +- `exclude` matches the **base file name** (not the repository path) and supports `*` wildcards. It is a + filter over the files the repository ships, so a pattern naming a file that has since been deleted is a + no-op, never a warning or a failure. +- `readmeAsIndex` uses the repository `README.md` as the landing page and cannot be combined with + `rootPage`; it aliases `readme` to `index`. +- `order` is an explicit list of page slugs, applied before the `_Sidebar.md` order. +- `redirects` maps moved slugs (`previous: current`) to keep old URLs alive on every channel that + publishes the tree. Prefer the repository's own `_redirects.yml` in its docs root (ADR 0013), which + lands with the move and is read per channel; the catalogue's pairs win when both name a slug. +- If `docs` is set but the aggregation yields no pages, `just data-sync` fails loudly. + +## `docs.channels` — live and prerelease documentation (ADR 0009) + +A project whose default branch is ahead of its latest release can publish two +views of its documentation. Channels are optional; omitting them keeps one +`live` channel on the default branch. + +```yaml +docs: + source: github-path + path: docs/wiki + rootPage: Getting-Started.md + channels: + live: + ref: latest-stable # newest non-prerelease release tag + prerelease: + ref: default # the repository's default branch +``` + +| Field | Meaning | +| --- | --- | +| `channels.live` / `channels.prerelease` | At most these two; unknown keys are rejected | +| `ref` | `default` (default branch), `latest-stable`, `latest-prerelease`, or any branch/tag/commit used verbatim. Defaults: `live` → `latest-stable`, `prerelease` → `default` | +| `path`, `rootPage`, `readmeAsIndex`, `order`, `exclude` | Optional per-channel overrides of the shared block (a tag's docs tree can differ from `main`) | + +- **`live` is the default channel** and owns `/docs//…`; `prerelease` is + served at `/docs//prerelease/…`. A prerelease-only project is the default + channel and serves its docs at the canonical URLs. +- `latest-stable` with no stable release → live follows the default branch + (warning). `latest-prerelease` with no tag → the channel is skipped. Two + channels on the same ref → the non-default one is dropped. +- **Promotion is automatic.** The prerelease channel is published only while a + prerelease version outranks the newest stable release, comparing GitHub + release tags with NuGet versions. Publishing `3.0.0` retires + `3.0.0-prerelease.N` docs on the next build; the channel returns with the next + prerelease. No manifest edit or PR is needed to stop publishing it. +- `wiki` and `readme` sources reject `channels`: they have a single version. +- Use-case `docsPage` deep links resolve against the **default** channel. + +## Loader-enforced rules (`loadProjects()`) + +1. Schema validation with remediation text (unknown keys are rejected). +2. `projects[].repository.split('/')[0]` must equal `purview-dev`. +3. `related`, `supersedes`, and `supersededBy` must all reference existing project ids. +4. A package id may be declared by **exactly one** project for its registry (the key is + `registry:id`; an absent `registry` means `nuget`). +5. `experimental: true` is rejected when `status` is `archived` (schema refinement). +6. An asset `output` path may be declared by **exactly one** project, must be relative, and must + not contain a `..` segment. +7. The returned list is sorted by `order` ascending. + +## Defaults applied by the loader + +`featured: false`, `order: 1000`, `packages: []`, `related: []`, `useCases: []`, +`acknowledgments: []`, `install: 'nuget'`, `experimental: false`. Derived values (`repoOwner`, +`repoName`, `sourceUrl`, `issuesUrl`, `discussions`, `discussionsUrl`, `changelogUrl`, `releasesUrl`) are computed, +never authored. + +## `externalProjects[]` fields + +Required: `id` (slug), `name`, `shortDescription`, `description`, `url` (valid URL), `category`. +Optional: `repository` (`owner/repo`, link target), `status`, `featured`, `order`. +Loader-enforced: a collaboration entry must **not** use the `purview-dev` owner (the manifest test +asserts that no `externalProjects` id starts with `purview-`). diff --git a/.agents/skills/purview-buildsdk-adoption/SKILL.md b/.agents/skills/purview-buildsdk-adoption/SKILL.md index bb24fa32..07be90ab 100644 --- a/.agents/skills/purview-buildsdk-adoption/SKILL.md +++ b/.agents/skills/purview-buildsdk-adoption/SKILL.md @@ -1,6 +1,6 @@ --- 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. +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, target framework 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, required settings and versions, target frameworks, and version detection from package.json. license: MIT metadata: package: Purview.BuildSdk @@ -41,7 +41,25 @@ defaults — naming, identity, framework selection — but none of the targets, 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. +### Required settings and versions + +Four values decide whether the SDK behaves as documented. Set the first three **before** the import +where noted: + +| What | Where | Notes | +| -- | -- | -- | +| `NamespacePrefix` | `Directory.Build.props`, above the import | Required — the build fails without it unless `DisableNamespacePrefixCheck=true`. | +| SDK version | `global.json` → `msbuild-sdks.Purview.BuildSdk` | The pinned `Purview.BuildSdk` version, e.g. `2.0.1`. The SDK bootstraps `global.json` when it is missing and writes `PurviewBuildSdkVersionForGlobalJson` — the restored package version, else the version it was packed as. | +| Product version | repo-root `package.json` → `version` | Drives `Version` and `PackageVersion`. `UsePackageJsonVersion` / `RootPackageJson` are pre-import. | +| Package versions | `Directory.Packages.props` | CPM is on; a `PackageVersion` is a minimum, not an exact pin. | + +```json +// global.json +{ + "test": { "runner": "Microsoft.Testing.Platform" }, + "msbuild-sdks": { "Purview.BuildSdk": "2.0.1" } +} +``` ## 2. Pre-import ordering is the first thing to check @@ -52,13 +70,17 @@ This is the single most common adoption failure. Set before the import: - `NamespacePrefix` -- `UsePackageJsonVersion`, `RootPackageJson` +- `UsePackageJsonVersion`, `RootPackageJson`, `RepoRoot` - `EnableAssemblyNameGeneration` -- `AutoIncludeUsings` -- `PurviewSharedTestingOutputType`, `PurviewTestContextNoWarn` -- any repo-wide testing-framework selection you want every project to inherit +- `TestingFramework`, `SubstituteFramework`, `TestDataFramework` — even for a single project; a + value in the `.csproj` is too late and the defaults (`TUnit`, `TUnitMocks`, `Bogus`) stay +- `PurviewTestContextNoWarn`, `DisablePurviewTestContextRuleSet` +- `DisablePurviewBlazorWebAssemblyDefaults` +- `PurviewTargetFrameworks`, when redefining a framework set -Everything else is read in `Sdk.targets` and can be set in a project file. +Most other properties are read in `Sdk.targets`, or only after the project body, and can be set in a +project file — `PurviewSharedTestingOutputType` and `AutoIncludeUsings` among them. `purview-buildsdk-configuration` lists +which is which. When a property appears to have no effect, confirm what MSBuild actually resolved before changing anything else: @@ -72,11 +94,14 @@ dotnet msbuild -getProperty:NamespacePrefix -getProperty:RootNamespace Versions come from `package.json`. Resolution order: 1. An explicit `RootPackageJson`. -2. CI workspace variables. -3. The `.git` root. -4. A nearby `package.json`. +2. An explicit `RepoRoot`, else CI workspace variables (`GITHUB_WORKSPACE`, + `BUILD_SOURCESDIRECTORY`, `BUILD_REPOSITORY_LOCALPATH`, `CI_PROJECT_DIR`). +3. The nearest `.git` above the project. +4. The nearest `package.json` above the project. -On success both `Version` and `PackageVersion` are set from the `version` field. +The `package.json` at that root is read, and on success both `Version` and `PackageVersion` are set +from its `version` field. A root that is found but has no `package.json`, or a `package.json` with no +`version`, already fails the build. If the version looks wrong, do not guess at the discovery — make it fail loudly instead: @@ -84,8 +109,8 @@ If the version looks wrong, do not guess at the discovery — make it fail loudl Strict ``` -`Strict` fails the build when discovery or resolution cannot succeed, rather than silently skipping -it and leaving the default `1.0.0`. Set `VersionDetectionLogEnabled=true` to see what it found. +`Strict` also fails the build when no repository root can be discovered at all, rather than silently +skipping detection and leaving the default `0.0.1`. Set `VersionDetectionLogEnabled=true` to see what it found. ## 4. Naming and placement @@ -106,9 +131,51 @@ cleaning them up are separate targets a person runs by name — `InstallPurviewS repository refuse to run inside a build (`PRSKL0009`). Do not hook them into one. The skills CLI does the installing, through `bun x` by default; with `PurviewSkillsAgents` unset it auto-detects agents. -If the repository uses `just`, add recipes for them — see `purview-buildsdk-skills-justfile`. For what each +If the repository uses `just`, add recipes for them — see `purview-buildsdk-justfile`. For what each target does and the `PRSKL*` diagnostics, see `purview-buildsdk-skill-installation`. +## 6. Target frameworks + +A project that declares neither `TargetFramework` nor `TargetFrameworks` gets `net10.0;net11.0`. +Confirm what a project resolves before changing it: + +```bash +dotnet msbuild -getProperty:TargetFramework -getProperty:TargetFrameworks +``` + +To set it: + +- **Declare it explicitly** — `TargetFramework` (one) or `TargetFrameworks` (many). An explicit value + always wins over every set, wherever it is set (the project, with or without a `Condition`, a file it + imports, or `Directory.Build.props`) — but not in `Directory.Build.targets`, which is too late. +- **Select a curated set** — `Supported` in + `Directory.Build.props` or the `.csproj` (`Current`, `Latest`, `Supported`, `Broad`, `All`), so a + lifecycle change is one SDK bump rather than an edit in every project. +- **Redefine a set** — set `PurviewTargetFrameworks` before the import to pin the repository's + meaning of a set. + +A Roslyn component targets `netstandard2.0` and ignores the sets. Full detail in +`purview-buildsdk-configuration` and [Configuration Reference](https://github.com/purview-dev/build-sdk/blob/main/docs/wiki/Configuration-Reference.md#target-framework-sets). + +## 7. SourceLink provider + +The SDK adds a SourceLink provider package (`SourceLinkPackageName`, default +`Microsoft.SourceLink.GitHub`) to every non-test project, and `PublishRepositoryUrl=true` writes the +repository URL and commit into the packed `.nuspec`. Match the repository host in +`Directory.Build.props`, so every project gets the same provider: + +| Host | Package | +| -- | -- | +| GitHub / GitHub Enterprise | `Microsoft.SourceLink.GitHub` *(default)* | +| Azure DevOps Services / Azure Repos | `Microsoft.SourceLink.AzureRepos.Git` | +| Azure DevOps Server / TFS (on-premises) | `Microsoft.SourceLink.AzureDevOpsServer.Git`, plus a `SourceLinkAzureDevOpsServerGitHost` item | +| GitLab | `Microsoft.SourceLink.GitLab` | +| Bitbucket | `Microsoft.SourceLink.Bitbucket.Git` | + +CPM is on, so add a `PackageVersion` for the chosen provider to `Directory.Packages.props` — the shipped +template already declares GitHub, Azure Repos and GitLab. `DisableSourceLink=true` opts out entirely. +Detail, including the on-premises host item, is in `purview-buildsdk-configuration`. + ## Constraints - Prefer minimal, targeted property changes over broad `Directory.Build.props` rewrites. diff --git a/.agents/skills/purview-buildsdk-configuration/SKILL.md b/.agents/skills/purview-buildsdk-configuration/SKILL.md index b540704b..767a81ae 100644 --- a/.agents/skills/purview-buildsdk-configuration/SKILL.md +++ b/.agents/skills/purview-buildsdk-configuration/SKILL.md @@ -1,6 +1,6 @@ --- 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, making a library browser-safe for a Blazor WebAssembly client, configuring a BFF host, 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. +description: Configure Purview.BuildSdk from Directory.Build.props or a .csproj. Use when setting NamespacePrefix, setting the target framework(s) or selecting a curated framework set, choosing a SourceLink provider for the repository host (GitHub, Azure DevOps, GitLab, Bitbucket), choosing a testing or mocking framework, turning telemetry or SourceLink off, controlling version detection from package.json, pinning the SDK version in global.json, tuning the repository bootstrap, making a library browser-safe for a Blazor WebAssembly client, configuring a BFF host, 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 @@ -25,14 +25,54 @@ Pre-import properties include: - `NamespacePrefix` - `UsePackageJsonVersion`, `RootPackageJson` - `EnableAssemblyNameGeneration` -- `PurviewSharedTestingOutputType`, `PurviewTestContextNoWarn` -- `AutoIncludeUsings` +- `PurviewTestContextNoWarn`, `DisablePurviewTestContextRuleSet` - `DisablePurviewBlazorWebAssemblyDefaults` -- repo-wide testing-framework selection you want every project to inherit +- `TestingFramework`, `SubstituteFramework`, `TestDataFramework` — even for a single project -Everything else is read in `Sdk.targets` and can be set in a project file. Prefer repo-wide defaults +A target framework is a special case. An explicit `TargetFramework`/`TargetFrameworks` always wins, +wherever it is set except `Directory.Build.targets`. `PurviewTargetFrameworkSet` is read from the +**project XML**, so it can live in the project or in `Directory.Build.props` but must be declared +**unconditionally** — see [Setting the target framework(s)](#setting-the-target-frameworks). + +Everything else is read in `Sdk.targets`, or only after the project body, and can be set in a project +file — `PurviewSharedTestingOutputType` and `AutoIncludeUsings` among them. Prefer repo-wide defaults in `Directory.Build.props` unless the setting is deliberately project-specific. +## Required settings and versions + +Four things must be right, and three of them are read during `Sdk.props` evaluation — set those in +`Directory.Build.props` **above** the ``: + +| Setting | Where | Notes | +| -- | -- | -- | +| `NamespacePrefix` | `Directory.Build.props`, pre-import | Required. The build fails without it (`ValidateRootNamespacePrefixTarget`) unless `DisableNamespacePrefixCheck=true`. | +| SDK version | `global.json` → `msbuild-sdks.Purview.BuildSdk` | The pinned `Purview.BuildSdk` version. The SDK bootstraps `global.json` (with this value) when it is missing; `PurviewBuildSdkVersionForGlobalJson` decides what is written. | +| Product version | repo-root `package.json` → `version` | Drives both `Version` and `PackageVersion`. `UsePackageJsonVersion` (`true`/`false`/`Strict`) and `RootPackageJson` are pre-import; `Strict` fails the build when discovery cannot succeed instead of silently staying on `0.0.1`. | +| Package versions | `Directory.Packages.props` | CPM is on (`ManagePackageVersionsCentrally=true`). Each `PackageVersion` is a minimum, not an exact pin. | + +## Setting the target framework(s) + +A project that declares neither `TargetFramework` nor `TargetFrameworks` gets the SDK's default set, +`net10.0;net11.0` (`Current`). Pick one of three routes, narrowest first: + +1. **Explicit declaration** — `net10.0` for one target, + `net10.0;net11.0` for many. An explicit value **always wins** + over every set, wherever it is set — the project (with or without a `Condition`), a file it + imports, or `Directory.Build.props` before or after the import. Only `Directory.Build.targets` is + too late, because the .NET SDK has already decided whether to multi-target. A project declaring + `TargetFrameworks` keeps `TargetFramework` empty. +2. **A curated set** — `Supported` in + `Directory.Build.props` (repository-wide) or the `.csproj`. Sets: `Current` (default), + `Latest` (`net11.0`), `Supported` (`net10.0;net11.0`), `Broad` (`net8.0;net9.0;net10.0`), `All` + (`net8.0;net9.0;net10.0;net11.0`). An unknown name fails with `PurviewInvalidTargetFrameworkSet` + rather than silently falling back. +3. **Redefine a set** — set `PurviewTargetFrameworks` (for example `PurviewTargetFrameworksSupported`) + **before the import** to pin the repository's meaning of a set across an SDK bump. + +A Roslyn component (`IsRoslynComponent=true`) targets `netstandard2.0` and ignores the sets; selecting +one is reported as `PurviewTargetFrameworkSetIgnored`. The full table is in +[references/properties.md](references/properties.md#target-framework-and-language). + ## The properties you will reach for most | Property | Default | What it does | @@ -45,16 +85,16 @@ in `Directory.Build.props` unless the setting is deliberately project-specific. | `IsPackable` | `false` | Opts a project into the packable defaults | | `UsePackageJsonVersion` | `true` | `true`, `false` or `Strict` — version from `package.json` | | `ExcludePurviewTelemetry` | `false` | Drops the telemetry source generator | -| `DisableSourceLink` | `false` | Drops SourceLink from packable projects | +| `DisableSourceLink` | `false` | Stops the SDK adding the SourceLink provider package to any project | | `EnableAssemblyNameGeneration` | `true` | `AssemblyName`/`PackageId` follow the resolved `RootNamespace` | | `PurviewBrowserSafe` | *(unset)* | On a class library a WebAssembly client downloads: trim, AOT and browser-unsupported APIs fail the build | | `IsBffProject` | name ends `Bff` | Classifies a BFF host; `false` opts out | | `PurviewBrowserSafeReferenceCheckAsError` | `false` | Makes `PRBLZ0001` (a client reference that is not browser-safe) an error | -| `MemorySafetyRules` | `2` | C# 15 memory-safety tier on `net11.0`+: `0` off, `1` preview syntax, `2` updated rules | -| `PurviewRuntimeAsync` | `true` | `runtime-async=on` on `net11.0`+ | +| `MemorySafetyRules` | `0` | C# 15 memory-safety tier on `net11.0`+: `0` off (default), `1` preview syntax, `2` updated rules (`updated-memory-safety-rules`, opt-in — an experimental compiler feature) | +| `PurviewRuntimeAsync` | *(unset, treated as false)* | Opts a `net11.0`+ target into `runtime-async=on`; `UseRuntimeAsync=true` also opts in | | `IsClientSideProject` | *(computed)* | Whether the project's assemblies can reach a browser; overrides the `ServerShared` classification | | `PurviewAutoServerSharedReference` | `true` | Adds a sibling `ServerShared` reference to a non-client-side project | -| `PurviewMultiThreadedBuild` | `true` | Bootstraps a `Directory.Build.rsp` enabling MSBuild `-mt` | +| `PurviewMultiThreadedBuild` | `true` | Bootstraps a `Directory.Build.rsp` enabling MSBuild `-mt`; unset, a Blazor WebAssembly client writes `-mt:false` (`PRBLZ0002`) | The full set, grouped by area and with the behaviour each one changes, is in [references/properties.md](references/properties.md). Read it rather than guessing a property name — @@ -79,10 +119,22 @@ 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). +The SDK exports a large set of properties to Roslyn analyzers and source generators through +`CompilerVisibleProperty`, readable as `build_property.`: + +```csharp +context.AnalyzerConfigOptions.GlobalOptions.TryGetValue("build_property.IsBffProject", out var isBff); +``` + +- **Consuming them** — read the exported property instead of re-deriving SDK behaviour; the export is + the SDK's answer and stays correct when the rules change. The SDK's own analyzers use this + (`build_property.IsBffProject` gates `PDS0008`/`PDS0009`). +- **Exposing a new one** — an analyzer can only see a property the SDK lists as a + `CompilerVisibleProperty` item (with a ``) in `Sdk.props`. Add it there; there is no + other mechanism, and an MSBuild property is invisible to the compiler until it is listed. + +The exported set is listed in +[references/properties.md](references/properties.md#compiler-visible-properties). ## Version detection @@ -102,7 +154,10 @@ The SDK bootstraps three repository-wide files from the package: `.editorconfig` - `PurviewRepoBootstrapCopyRetries` / `...RetryDelayMilliseconds` / `...FailureAsError` - `BootstrapMultiThreadedBuildRspToRepoRoot` / `PurviewMultiThreadedBuild` — the response file that enables MSBuild `-mt` (and `-nr:false`, which avoids a `RoslynCodeTaskFactory` cache left by a - non-`-mt` build) + non-`-mt` build). A repository with a Blazor WebAssembly client gets `-mt:false` instead, because a + `net11.0` client cannot build under `-mt`; a client built under `-mt` anyway fails with `PRBLZ0002`. + The WebAssembly SDK's own error for this is `MSB4060` on `ReadWasmProps`, and it does not mention the + response file Every project in a solution writes those same destinations, so the write is race-tolerant by design: an existing file is never overwritten unless asked, each write is staged into a temporary @@ -124,13 +179,32 @@ on the command line, and a repository-wide choice belongs in `Directory.Build.pr | `PurviewSkillsDryRun` | `false` | Install and update print commands instead of running them | On a command line, `dotnet msbuild -p:` splits values on `,` and `;` — write -`-p:PurviewSkillsAgents=claude-code%2Ccodex`. For `just` recipes, see `purview-buildsdk-skills-justfile`. - -## 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. +`-p:PurviewSkillsAgents=claude-code%2Ccodex`. For `just` recipes, see `purview-buildsdk-justfile`. + +## SourceLink + +Every non-test project gets a SourceLink provider package — `SourceLinkPackageName`, default +`Microsoft.SourceLink.GitHub`, added with `PrivateAssets="all"` so a debugger can step into a consumed +package's source and the `.nuspec` records the repository URL and commit. A repository on another host +selects the matching package in `Directory.Build.props`, so every project agrees: + +| Host | Provider package | +| -- | -- | +| GitHub / GitHub Enterprise | `Microsoft.SourceLink.GitHub` *(default)* | +| Azure DevOps Services / Azure Repos | `Microsoft.SourceLink.AzureRepos.Git` | +| Azure DevOps Server / TFS (on-premises) | `Microsoft.SourceLink.AzureDevOpsServer.Git` (+ `SourceLinkAzureDevOpsServerGitHost`) | +| GitLab | `Microsoft.SourceLink.GitLab` | +| Bitbucket | `Microsoft.SourceLink.Bitbucket.Git` | + +Because CPM is on, the chosen provider also needs a `PackageVersion` in `Directory.Packages.props`. Use +`DisableSourceLink=true` (in `Directory.Build.props`, or one project) to opt out. The host matrix, the on-premises Azure DevOps Server +host item, the CPM requirement and the self-hosted case are in +[references/sourcelink.md](references/sourcelink.md). + +## Guidance when changing configuration + +1. Preserve existing defaults unless the task explicitly changes behaviour, and do not restate an SDK + default in a project file. +2. Confirm a change with `dotnet msbuild -getProperty:` rather than reasoning about it. +3. For a package that ships its **own** MSBuild properties — adding one, renaming it, documenting it in + a `.buildschema.json` sidecar, or exporting it to analyzers — use `purview-buildsdk-msbuild-properties`. diff --git a/.agents/skills/purview-buildsdk-configuration/references/properties.md b/.agents/skills/purview-buildsdk-configuration/references/properties.md index 82f84d77..8efd3db9 100644 --- a/.agents/skills/purview-buildsdk-configuration/references/properties.md +++ b/.agents/skills/purview-buildsdk-configuration/references/properties.md @@ -12,21 +12,33 @@ consuming project supplies nothing. | `EnableAssemblyNameGeneration` | `true` | `AssemblyName` and the default `PackageId` follow the fully evaluated `RootNamespace`, or the full logical project name when suffix-stripping removed a segment (`Shared`, `ServiceDefaults`). Set `false` before the SDK import for standard project-name behaviour | | `AssemblyIdentifierClassName` | *(derived)* | Renames the generated marker class | | `DisableGenerateAssemblyIdentifierClass` | `false` (forced `true` for test and shared-testing projects) | Drops the empty marker class (`Assembly`, global namespace) that architecture tests use to pin an assembly by `typeof` rather than by name | -| `DisableGenerateAssemblyInfoClass` | `false` | Drops the generated `AssemblyInfo` | +| `DisableGenerateAssemblyInfoClass` | `false` (forced `true` for test and shared-testing projects) | Drops the generated `AssemblyInfo` | | `DisableAutoInternalsVisibleTo` | `false` | Drops automatic friend-assembly generation | | `DisableProjectFileNamingConventionCheck` | `false` | Stops validating that the `.csproj` filename matches its directory | -| `AutoIncludeUsings` | `true` | Controls the SDK-added global usings. Set before the SDK import | +| `AutoIncludeUsings` | `true` | Controls the SDK-added global usings. Can be set in a project file | ## Target framework and language | Property | Default | Behaviour | | -- | -- | -- | -| `TargetFramework` | `net10.0;net11.0` | Applied when neither `TargetFramework` nor `TargetFrameworks` is set. A project that explicitly declares `IsRoslynComponent=true` defaults to `netstandard2.0` | +| `TargetFramework` | `net10.0;net11.0` | Applied when neither `TargetFramework` nor `TargetFrameworks` is set. An explicit value (or `TargetFrameworks`) always wins over every set, wherever it is set — with or without a `Condition` — except `Directory.Build.targets`, which is too late. A project that explicitly declares `IsRoslynComponent=true` defaults to `netstandard2.0` | +| `PurviewTargetFrameworkSet` | `Current` | Named set resolved when the project declares no TFM: `Current`, `Latest`, `Supported`, `Broad` or `All`. An unknown name fails with `PurviewInvalidTargetFrameworkSet`. A single-entry set resolves to `TargetFramework` rather than paying for an outer build. Declare unconditionally | +| `PurviewTargetFrameworksCurrent` | `net10.0;net11.0` | Redefines the `Current` set (the default). Set before the SDK import | +| `PurviewTargetFrameworksLatest` | `net11.0` | Redefines the `Latest` set | +| `PurviewTargetFrameworksSupported` | `net10.0;net11.0` | Redefines the `Supported` set | +| `PurviewTargetFrameworksBroad` | `net8.0;net9.0;net10.0` | Redefines the `Broad` set | +| `PurviewTargetFrameworksAll` | `net8.0;net9.0;net10.0;net11.0` | Redefines the `All` set | +| `TreatWarningsAsErrors` | `true` | Every project: compiler, analyzer and NuGet warnings fail the build (MSBuild `` tasks do not). `false` in a project or `Directory.Build.props` opts out. A packable Roslyn component still fails its pack without it (`PRSGD0003`) | | `IsRoslynComponent` | *(unset)* | When explicitly `true`: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, no dependency file, telemetry exclusion, and `PrivateAssets=all` on `Microsoft.CodeAnalysis.*` references | | `IsRoslynComponentOnly` | `true` for Roslyn components | Analyzer-only package: `IncludeBuildOutput=false`, `IncludeSymbols=false`, portable PDB packed beside the analyzer under `analyzers/dotnet/cs/`. Set `false` for a dual-role component using normal library symbol packaging | | `DisableRoslynCompilerDefaultsValidation` | `false` | Opts out of `ValidateRoslynComponentCompilerSettings`, which fails the pack of a packable Roslyn component missing `LangVersion`, `Nullable`, `TreatWarningsAsErrors` or `EnforceExtendedAnalyzerRules` | | `PackProjectReferencedSourceGenerators` | `true` | Packable projects include analyzer `ProjectReference` outputs under `analyzers/dotnet/cs/`. Use `Pack="false"` on one reference to opt that one out | +Overrides are layered, narrowest first: an explicit `TargetFramework`/`TargetFrameworks` in the project +always wins, then a `PurviewTargetFrameworkSet` in the project, then a set the repository selected, then +a `PurviewTargetFrameworks` redefinition, then the SDK default. Roslyn components are not covered — +they target `netstandard2.0`; selecting a set on one is reported as `PurviewTargetFrameworkSetIgnored`. + ## Style policy | Property | Default | Behaviour | @@ -40,11 +52,11 @@ consuming project supplies nothing. | Property | Default | Behaviour | | -- | -- | -- | -| `MemorySafetyRules` | `2` | C# 15 memory-safety tier on `net11.0`+ targets: `0` off, `1` preview pointer syntax (sets `LangVersion=preview` when none was chosen), `2` `updated-memory-safety-rules`. Unknown values fail with `PRCFG0001`. Never touches `AllowUnsafeBlocks` | -| `PurviewRuntimeAsync` | `true` | `runtime-async=on` on `net11.0`+ targets. `UseRuntimeAsync=false` also opts out | -| `PurviewMultiThreadedBuild` | `true` | Bootstraps a managed `Directory.Build.rsp` with `-mt`/`-nr:false`; `false` writes `-mt:false` | +| `MemorySafetyRules` | `0` | C# 15 memory-safety tier on `net11.0`+ targets: `0` off (default), `1` preview pointer syntax (sets `LangVersion=preview` when none was chosen), `2` `updated-memory-safety-rules`. Tier `2` is opt-in — the updated rules are an experimental compiler feature. Unknown values fail with `PRCFG0001`. Never touches `AllowUnsafeBlocks` | +| `PurviewRuntimeAsync` | *(unset, treated as false)* | Set to `true` for `runtime-async=on` on `net11.0`+ targets. `UseRuntimeAsync=true` also opts in; an explicit `false` under either name keeps it off | +| `PurviewMultiThreadedBuild` | `true` | Bootstraps a managed `Directory.Build.rsp` with `-mt`/`-nr:false`; `false` writes `-mt:false`. Unset, a Blazor WebAssembly client writes `-mt:false` for the repository, and a client built under `-mt` anyway fails with `PRBLZ0002`; an explicit value always wins | | `BootstrapMultiThreadedBuildRspToRepoRoot` | `true` | Whether that bootstrapped response file is written at all | -| `IsClientSideProject` | *(computed)* | `true` for a WebAssembly client, `PurviewBrowserSafe` library, Razor class library, or `*.Client`/`*.Browser`/`*.Wasm`/`*.WebAssembly`/`*.Components` name. Explicit value wins | +| `IsClientSideProject` | *(computed)* | `true` for a WebAssembly client, `PurviewBrowserSafe` library, Razor class library, or `*.Client`/`*.ClientShared`/`*.Browser`/`*.Wasm`/`*.WebAssembly`/`*.Components` (or `ClientShared`/`Components`) name. Explicit value wins | | `PurviewAutoServerSharedReference` | `true` | Adds `../ServerShared*/ServerShared*.csproj` to a non-client-side project | | `PurviewServerSharedReferenceCheckAsError` | `false` | Makes `PRSHR0001`/`PRSHR0002` errors | | `DisablePurviewServerSharedReferenceCheck` | `false` | Turns `PRSHR0001`/`PRSHR0002` off | @@ -60,7 +72,7 @@ consuming project supplies nothing. | `EnableStaticNativeInstrumentation` | `false` for test projects | | | `EnableDynamicNativeInstrumentation` | `false` for test projects | | | `TestingPlatformDotnetTestSupport`, `UseMicrosoftTestingPlatformRunner`, `EnableMicrosoftTestingPlatform` | enabled for TUnit test projects | Microsoft.Testing.Platform wiring | -| `PurviewSharedTestingOutputType` | `Library` | Forced onto `IsSharedTestingProject` projects, with `IsTestProject`/`IsTestingPlatformApplication` cleared, because the test packages otherwise flip them into an executable test host. Set `Exe` before the SDK import to keep the package-driven shape | +| `PurviewSharedTestingOutputType` | `Library` | Forced onto `IsSharedTestingProject` projects, with `IsTestProject`/`IsTestingPlatformApplication` cleared, because the test packages otherwise flip them into an executable test host. Set `Exe` (in the project or `Directory.Build.props`) to keep the package-driven shape | ## Packaging @@ -76,8 +88,9 @@ Packable-project defaults, applied only when the project supplied nothing: in the `.snupkg`; the `.nupkg` gets none unless the project opts in. Roslyn-component-only packages instead default `IncludeSymbols=false` and ship their PDB inside `analyzers/dotnet/cs/`. -When the repository root is discoverable, the root `README.md` is packed automatically and -registered as `PackageReadmeFile`, unless one is configured already. +Unless `PackageReadmeFile` is configured, a README is packed and registered automatically: the project's +own `README.md` (beside its `.csproj`) wins, else the repository-root one. A README-named file already +being packed, such as an `Sdk/README.md`, is used instead. Non-packable projects, including web apps, default `WarnOnPackingNonPackableProject=false` so a solution-wide pack skips them silently. @@ -91,8 +104,14 @@ forces SourceLink's CI-mode dirty-repository checks. | -- | -- | -- | | `ExcludePurviewTelemetry` | `false` | Removes `Purview.Telemetry.SourceGenerator` | | `ExcludeMSTelemetryExtension` | `false` | Removes `Microsoft.Extensions.Telemetry.Abstractions`. Only relevant when `ExcludePurviewTelemetry` is also `false` — the whole group is skipped otherwise | -| `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | | -| `DisableSourceLink` | `false` | | +| `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | The provider package, added with `PrivateAssets=all` to every non-test project. Set it in `Directory.Build.props` to match the host — `Microsoft.SourceLink.AzureRepos.Git` (Azure DevOps Services), `Microsoft.SourceLink.AzureDevOpsServer.Git` (on-premises ADO Server / TFS), `Microsoft.SourceLink.GitLab`, `Microsoft.SourceLink.Bitbucket.Git` | +| `DisableSourceLink` | `false` | `true` stops the SDK adding the package at all. An empty `SourceLinkPackageName` does **not** opt out — the GitHub default fills it | +| `EnableSourceLink` | `true` (set by the provider) | The provider package's own switch; `false` keeps the package but skips the source mapping | + +Both are read only on the package reference the SDK adds, which MSBuild evaluates after the project body, +so a `.csproj` value works too; `Directory.Build.props` keeps every project on one provider. See +[sourcelink.md](sourcelink.md) for the host matrix, Azure DevOps specifics, the CPM `PackageVersion` +requirement and self-hosted hosts. ## Blazor WebAssembly, browser-safe libraries and BFF hosts @@ -103,11 +122,11 @@ forces SourceLink's CI-mode dirty-repository checks. | `PurviewAutoBlazorClientReference` | `true` | A host named `` references its `.Client` WebAssembly client automatically. Skipped when the reference is already declared | | `DisablePurviewBlazorWebAssemblyDefaults` | `false` | Keeps the generic defaults on a WebAssembly client: packable when asked, Dockerfile container detection, the automatic `Shared` reference. Set before the SDK import | | `PurviewBrowserSafe` | *(unset)* | `true` on a class library applies `IsAotCompatible`, `SupportedPlatform browser` and the browser-safe `WarningsAsErrors`, each only where the project set nothing | -| `PurviewBrowserSafeWarningsAsErrors` | `true` | `false` keeps the trim and AOT diagnostics as warnings; `CA1416` then falls back to the repository's severity (`suggestion` in the shipped `.editorconfig`) | +| `PurviewBrowserSafeWarningsAsErrors` | `true` on a `PurviewBrowserSafe` library, otherwise unset | `false` hands the trim and AOT diagnostics back to `TreatWarningsAsErrors` (`true` by default); `CA1416` then falls back to the repository's severity (`suggestion` in the shipped `.editorconfig`) | | `PurviewBrowserSafeWarningCodes` | `CA1416`, `IL2001`-`IL2128`, `IL3000`-`IL3005`, `IL3050`-`IL3059` | The ids a browser-safe library adds to `WarningsAsErrors` | | `DisablePurviewBrowserSafeReferenceCheck` | `false` | Turns off `PRBLZ0001`, the check that every project a client (or browser-safe library) references sets `PurviewBrowserSafe` | | `PurviewBrowserSafeReferenceCheckAsError` | `false` | Reports `PRBLZ0001` as an error | -| `PurviewBffExposesApi` | `true` when the BFF references `Microsoft.AspNetCore.OpenApi`, else `false` | Whether a BFF keeps the `Microsoft.AspNetCore.OpenApi.Generated` interceptor namespace | +| `PurviewBffExposesApi` | `true` when the BFF references `Microsoft.AspNetCore.OpenApi`, otherwise unset | Whether a BFF keeps the `Microsoft.AspNetCore.OpenApi.Generated` interceptor namespace | `PurviewBrowserSafe="true"` is also accepted as `ProjectReference` metadata, recording that a reference was judged browser-safe when it cannot say so itself. @@ -117,7 +136,7 @@ reference was judged browser-safe when it cannot say so itself. | Property | Default | Behaviour | | -- | -- | -- | | `UsePackageJsonVersion` | `true` | `true`, `false`, `Strict` | -| `RootPackageJson` | *(discovered)* | Explicit path to the `package.json` to read | +| `RootPackageJson` | *(discovered)* | Explicit path to the `package.json` to read. A relative path resolves from the project directory; in `Directory.Build.props` use `$(MSBuildThisFileDirectory)package.json` | | `EnableVersionDetectionCache` | `true` | | | `VersionDetectionCacheFile` | *(derived)* | | | `VersionDetectionLogEnabled` | `false` | Logs the detected version | @@ -134,7 +153,7 @@ reference was judged browser-safe when it cannot say so itself. | `RepositoryGlobalJsonFilePath` | *(derived)* | Overrides the destination `global.json` | | `BootstrapMultiThreadedBuildRspToRepoRoot` | `true` | Creates a managed `Directory.Build.rsp` enabling `-mt` | | `RepositoryDirectoryBuildRspFilePath` | *(derived)* | Overrides the destination `Directory.Build.rsp` | -| `PurviewMultiThreadedBuild` | `true` | `false` rewrites a managed rsp to `-mt:false` | +| `PurviewMultiThreadedBuild` | `true` | `false` rewrites a managed rsp to `-mt:false`; unset, a WebAssembly client writes `-mt:false` | | `PurviewBuildSdkVersionForGlobalJson` | restored package version, else the version it was packed as | | | `PurviewRepoBootstrapCopyRetries` | `3` | | | `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | | @@ -156,7 +175,19 @@ Covered in full by `purview-buildsdk-skill-installation`: `PurviewSkillsRunner`, ## Compiler-visible properties -Exported to analyzers and generators as `build_property.`: +The SDK lists these as `CompilerVisibleProperty` items, so Roslyn analyzers and source generators read +them as `build_property.`: + +```csharp +context.AnalyzerConfigOptions.GlobalOptions.TryGetValue("build_property.IsBffProject", out var isBff); +``` + +Read the exported property rather than re-deriving SDK behaviour — it is the SDK's own answer and stays +correct when the rules change. To make a new property visible, add a `CompilerVisibleProperty` item +(with a ``) in `Sdk.props`; nothing else exposes an MSBuild property to the compiler. + +Among the exported properties (the full list, with descriptions, is the *Compiler-visible SDK properties* +table in the [Configuration Reference](https://github.com/purview-dev/build-sdk/blob/main/docs/wiki/Configuration-Reference.md#compiler-visible-sdk-properties)): - `UsePackageJsonVersion`, `RootPackageJson`, `RepoRoot`, `Version`, `PackageVersion` - `NamespacePrefix`, `DisableNamespacePrefixCheck` diff --git a/.agents/skills/purview-buildsdk-configuration/references/sourcelink.md b/.agents/skills/purview-buildsdk-configuration/references/sourcelink.md new file mode 100644 index 00000000..aafa6231 --- /dev/null +++ b/.agents/skills/purview-buildsdk-configuration/references/sourcelink.md @@ -0,0 +1,80 @@ +# SourceLink + +Every non-test project gets a SourceLink **provider** package, added with `PrivateAssets="all"`, so a +debugger can step into a consumed package's source and the packed `.nuspec` records the repository URL +and the exact commit (`PublishRepositoryUrl=true`). Test and shared-testing projects are excluded, +because their symbols are never published. + +## Property reference + +| Property | Default | Behaviour | +| -- | -- | -- | +| `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | The provider package | +| `DisableSourceLink` | `false` | `true` stops the package being added at all | +| `EnableSourceLink` | `true` (set by the provider) | The provider package's own switch; `false` keeps the package but skips the source mapping | + +Set `SourceLinkPackageName` and `DisableSourceLink` in `Directory.Build.props`, so every project agrees. +A `.csproj` value also works: the SDK reads both only on the package reference it adds, and MSBuild +evaluates that after the project body. `EnableSourceLink` belongs to the provider package. An **empty** +`SourceLinkPackageName` does not disable SourceLink: the SDK fills it with the GitHub default. + +## Choosing a provider for the host + +| Host | `SourceLinkPackageName` | Extra configuration | +| -- | -- | -- | +| GitHub / GitHub Enterprise | `Microsoft.SourceLink.GitHub` *(default)* | none | +| Azure DevOps Services / Azure Repos (`dev.azure.com`, `*.visualstudio.com`) | `Microsoft.SourceLink.AzureRepos.Git` | none | +| Azure DevOps Server / TFS (on-premises) | `Microsoft.SourceLink.AzureDevOpsServer.Git` | a `SourceLinkAzureDevOpsServerGitHost` item | +| GitLab (12.0+) | `Microsoft.SourceLink.GitLab` | older GitLab: `SourceLinkGitLabHost` + `Version` | +| Bitbucket Cloud | `Microsoft.SourceLink.Bitbucket.Git` | Bitbucket Server / DC < 4.7: `SourceLinkBitbucketGitHost` + `Version` | +| anything else / self-hosted | *(none built in)* | a third-party or hand-rolled provider, or opt out | + +```xml + + + Microsoft.SourceLink.AzureRepos.Git + +``` + +### Azure DevOps + +- **Azure DevOps Services** — `Microsoft.SourceLink.AzureRepos.Git`, and nothing else. The provider + reads the `origin` remote and derives the content URLs itself. +- **Azure DevOps Server / TFS (on-premises)** — `Microsoft.SourceLink.AzureDevOpsServer.Git` plus the + host, because the virtual directory cannot be inferred from the repository URL: + + ```xml + + + + ``` + + `Include` is the domain, optionally with a port (`tfs.example.com:8080`). A path + (`tfs.example.com/tfs`) fails the provider's own validation ("not a valid domain name"), so the + virtual directory is a separate attribute. +- Azure Pipelines sets `TF_BUILD`, so `ContinuousIntegrationBuild` is on there with no configuration. + +## Central Package Management + +CPM is on, so the chosen provider needs a `PackageVersion` in `Directory.Packages.props`. The shipped +`templates/Directory.Packages.props` declares `Microsoft.SourceLink.GitHub`, +`Microsoft.SourceLink.AzureRepos.Git` and `Microsoft.SourceLink.GitLab` at `$(SourceLinkVersion)`. A +provider that is selected but not declared fails the restore with `NU1010`. + +## Continuous integration + +`ContinuousIntegrationBuild` is set to `true` only when `CI`, `GITHUB_ACTIONS` or `TF_BUILD` is `true` +(`TF_BUILD` is Azure Pipelines). A local build is deliberately **not** treated as CI, because +SourceLink's CI-mode dirty-repository checks would otherwise fail a local `TreatWarningsAsErrors` build. + +## Opting out, and self-hosted hosts + +`DisableSourceLink=true` stops the SDK adding the package. For a host with no +official provider, bring a source-control package (`Microsoft.Build.Tasks.Git`) and a provider package +in yourself and set `DisableSourceLink=true`, so the GitHub default is not added alongside them. + +## Why it matters beyond debugging + +`PublishRepositoryUrl=true` writes `` into the `.nuspec`, so anyone +holding the package can find the exact commit it was built from — the commit to pin, for example, when +installing that repository's skills with `bun x skills add owner/repo#`. diff --git a/.agents/skills/purview-buildsdk-docs-sync/SKILL.md b/.agents/skills/purview-buildsdk-docs-sync/SKILL.md new file mode 100644 index 00000000..9661abc6 --- /dev/null +++ b/.agents/skills/purview-buildsdk-docs-sync/SKILL.md @@ -0,0 +1,145 @@ +--- +name: purview-buildsdk-docs-sync +description: Bring a Purview.BuildSdk repository's written surfaces back in line with its code - the authored skills (skills/*/SKILL.md and references/), the agent instructions (AGENTS.md, CLAUDE.md, .github/copilot-instructions.md), the READMEs (repository root, each project's README.md, a project's Sdk/README.md, which is what its package ships) and the docs/wiki/ pages - and keep the wiki hierarchical, one topic folder per area, with _Sidebar.md and Home.md in step. Use after a feature, property, recipe, diagnostic or project is added, renamed or removed; when docs or skills may have drifted from the code; when reorganising or adding wiki pages; or when asked to audit or "sync the docs". Every statement is checked against the code, never against another document. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Keeping skills, agent instructions, READMEs and the wiki true + +A repository describes itself in four places, read by different people: + +| Surface | Read by | Lives in | +| -- | -- | -- | +| Authored skills | agents in *other* repositories, after `npx skills add` | `skills//SKILL.md`, `skills//references/*.md` | +| Agent instructions | agents working *in* this repository | `AGENTS.md`; `CLAUDE.md` and `.github/copilot-instructions.md` are thin wrappers that point at it | +| READMEs | GitHub visitors, and NuGet users of each package | the repository root, each project folder, a project's `Sdk/` folder | +| Wiki | readers of the documentation site | `docs/wiki/` | + +They drift independently, and nothing fails when they do: the reader simply follows a document that +describes last release's code. **The code is the only source of truth.** Never correct one document +from another — two documents agreeing proves nothing if both are stale. + +## The one rule + +**Every claim is checked against the code before it is kept, written or changed.** A claim is +anything a reader could act on: a property name and its default, a recipe, a command, a diagnostic +id, a file path, a package id, a target framework, a default behaviour, a version. Look each one up: +read the `.props`/`.targets`, the `Justfile` and its modules, the analyzer's descriptor, the +`.csproj`, the test that pins the behaviour. A claim you cannot find in the code is **removed or +marked as a question for the user** — never kept on trust, never guessed. + +How to check each kind of claim: [references/truth-audit.md](references/truth-audit.md). + +## Which README is which + +What a package ships as its README decides which file a NuGet user reads, so work it out from the +build, not from the file names. With Purview.BuildSdk, for a packable project with no explicit +`PackageReadmeFile`: + +1. the project's own `README.md`, beside its `.csproj`, wins; +2. otherwise the repository-root `README.md` is packed; +3. a file under the project's `Sdk/` folder named `*.md` is packed to the package root by + `PurviewAutoSdkPack`, so `Sdk/README.md` *is* the package README when the project packs `Sdk/`. + +So in a repository with several packages, each package's README is the per-project one, and the root +`README.md` describes the repository as a whole. A project README covers only what that package +does, how to install it and its own settings; it links to the wiki for the rest rather than copying +it. Check which file each project actually packs (`dotnet pack`, then list the `.nupkg`) before +editing one, and say in the root README which per-project READMEs exist. + +## The wiki is hierarchical + +`docs/wiki/` is one folder per topic area, plus a few root pages. Two index files tie it together: + +- `_Sidebar.md` — every page exactly once, grouped into sections (`- **Section**`), in reading order. + The documentation site reads its links in document order for page order, and does not publish it. +- `Home.md` — the browsing index: a short intro, a "How this wiki is organised" table, then one + `## Section` heading per sidebar section, each with a `| Page | What it answers |` table. + +The rules, the published-URL consequences, and the test that enforces them: +[references/wiki-structure.md](references/wiki-structure.md). The short form: + +- a page lives at the wiki root **only** when the site depends on its URL: the landing page + (`docs.rootPage`) and any page the site's project catalogue deep-links (`docsPage`). Everything else + is in exactly one topic folder, never deeper; +- folder and page names are letters, digits and single dashes (`configuration/Property-Schema.md`); +- each page opens with exactly one `# ` title, then a one-paragraph description of what it answers; +- links are relative to the file they are written in (`../packaging/SourceLink.md` from a folder page); +- moving a page changes its published URL — record the old and new slugs so the site can redirect. + +## The sweep + +Run it as a sweep, not an edit: a change that lands in one surface and stops there is exactly the +drift this skill exists to prevent. + +1. **Inventory the change.** From the diff (or, for an audit, the code): what was added, renamed, + re-defaulted or removed — properties, targets, recipes, diagnostics, projects, packages, skills, + files. Each item is a row in the sweep. +2. **Find every mention.** For each item, search all four surfaces, including old names: + `git grep -n -e OldName -e NewName -- '*.md'`. Also search `CHANGELOG.md`, but never rewrite + released entries — they are history. +3. **Correct each mention against the code** (the one rule). Prefer deleting a duplicated explanation + and linking to its one home over keeping two copies in step. +4. **Skills** + - Update the `description` so the skill *fires* on the new question, and keep it under 1024 + characters. + - Keep `SKILL.md` under 500 lines, with detail in `references/`. + - A skill describes what a **consumer** does. Guidance about working in this repository belongs in + `AGENTS.md`, or in a repository-local skill under `.agents/skills/`, which is not published. + - A new skill is named `--`, and its folder name matches its `name`. +5. **Agent instructions.** + - `AGENTS.md` carries the skill table (one row per authored skill, linking its authored path + `./skills//SKILL.md`, never `.agents/skills/`), the repository map, the canonical commands + and the invariants a contributor must not break. + - The wrappers stay one-line pointers to it. +6. **READMEs.** + - The root README covers what the repository ships, the quick start, the feature summary and where + the docs are. + - Each per-project README covers its own package. + - Long reference tables belong in the wiki. Keep only those the tests hold the README to. +7. **Wiki.** + - Update the page that owns the topic. + - A new page goes in its topic's folder and gets added to `_Sidebar.md` (once, in reading order) + and to that section's table in `Home.md`. + - A new topic area gets a folder, a sidebar section and a `Home.md` heading, all with the same + name. +8. **Changelog.** Add one entry per user-visible change: `### Documentation` for a docs-only fix, + naming what was wrong. It goes under the heading for the version in `package.json` + (`## [x.y.z] - `, created when a bump moves the version), **not** under `[Unreleased]`. A + merge to `main` publishes that version, so an entry parked under `[Unreleased]` reads as unreleased + until it is already live, and the changelog is out of date the moment it ships. `[Unreleased]` is + only for a pre-release version (`x.y.z-alpha.1`, `-beta`, `-rc`), whose changes genuinely have not + shipped as a release yet. +9. **Verify** (below), and state the commands you ran. + +## Verify + +Each check proves something different, so run all that the repository has: + +```bash +git grep -n -e '' -- '*.md' # nothing stale left +just skills-validate # PRSKL0005: every skill well formed, every references/ link resolves +just test # the repository's doc tests: wiki structure, skill rules, property tables +``` + +- **The wiki test:** if the repository has none, add one (see + [references/wiki-structure.md](references/wiki-structure.md#the-test)). Without it, every rule + above is a convention nobody checks. +- **Links outside the wiki:** for every link a README or skill makes into `docs/wiki/`, open the + target and confirm the page and the `#anchor` exist. +- **Skill references:** follow each relative link once. + +## Guardrails + +- **Do not change behaviour to match a document.** When the code and the docs disagree, the docs are + wrong — unless the user says the code is. Report a disagreement that looks like a code bug rather + than documenting the bug as intended. +- **Do not invent.** No undocumented defaults, no example output you did not run, no version numbers + from memory. +- **Do not move a root page** the site deep-links without telling the user. It breaks inbound links + outside this repository. +- **Do not rewrite released changelog entries,** and do not touch generated files. +- Keep the sweep to documentation: a refactor found along the way is a separate change. diff --git a/.agents/skills/purview-buildsdk-docs-sync/references/truth-audit.md b/.agents/skills/purview-buildsdk-docs-sync/references/truth-audit.md new file mode 100644 index 00000000..3da13b74 --- /dev/null +++ b/.agents/skills/purview-buildsdk-docs-sync/references/truth-audit.md @@ -0,0 +1,41 @@ +# Checking a claim against the code + +For each kind of statement a document makes, where the truth lives and how to look it up. Search with +`git grep -n`, which only reads tracked files, and read the definition itself rather than a comment +that describes it. + +| Claim | The truth | How to check | +| -- | -- | -- | +| An MSBuild property exists, and its default | the `.props`/`.targets` that define it (`Sdk/Props/*.props`, `Sdk/Sdk.props`, `Sdk/Sdk.targets`, `Sdk/Targets/*.targets`), and the `.buildschema.json` sidecar | `git grep -n ' -getProperty:Name` | +| A property must be set before the import | whether `Sdk.props`, or a file it imports, *reads* it | a read in a value or `Condition` in `Sdk.props`'s import graph, other than its own `== ''` guard, makes it pre-import | +| An analyzer reads `build_property.X` | a `CompilerVisibleProperty Include="X"` item, and the analyzer source | `git grep -n 'CompilerVisibleProperty' -- '*.props'` and `git grep -n 'build_property.X'` | +| A diagnostic id, its severity and its meaning | the `DiagnosticDescriptor` (analyzers) or the `Code="..."` on an MSBuild `Error`/`Warning`/`Message` (targets) | `git grep -n 'PRSKL0012'`. The severity is the descriptor's default or the task's `Importance`, not the docs table | +| A target exists and what it runs | the ``, its `DependsOnTargets`, `BeforeTargets`/`AfterTargets` | read the target; "never runs during a build" means it has no hook *and* nothing hooks it | +| A `just` recipe and what it does | the `Justfile` and every file it imports | `just --list` for names, `just --dry-run ` for the exact command — never read a recipe name off another document | +| A command line works | running it | run it, or `--dry-run`/`-p:...DryRun=true` when it changes things; do not quote output you did not see | +| A file or folder path | the file system | `git ls-files `, which also proves it is tracked rather than generated | +| A package id, and what it contains | the `.csproj` (`PackageId`, `IsPackable`) and the packed `.nupkg` | `dotnet pack`, then list the archive; pack validation config (for example `.config/purview-build.json` `RequiredContent`) lists the expected files | +| A target framework or framework set | the `.props` defining the sets, and the project's evaluated value | `dotnet msbuild -getProperty:TargetFrameworks` | +| A version | `package.json` `version`, `global.json` `msbuild-sdks`, `Directory.Packages.props` | read the file. A quick-start snippet's pin is checked by the repository's version-sync check, if there is one | +| A skill exists, and what it answers | `skills//SKILL.md` frontmatter | `name` and `description`; a skill listed in `AGENTS.md` must exist, and every authored skill must be listed | +| "Tested by X" | the test | `git grep -n 'X'` under the test folder, and read the test: does it assert what the document says it does? | +| Behaviour ("the SDK does Y when Z") | the code path, and ideally the test that pins it | find both. If no test pins it, say so in your report rather than strengthening the claim | + +## Reading a document for claims + +Go through a page section by section and list every checkable statement before changing anything. +For each, record **kept** (found, matches), **fixed** (found, differs — the code wins), **removed** +(not found anywhere) or **question** (found but ambiguous, or the code looks wrong). Report the +removed and question rows to the user: a removed claim may describe something that should exist. + +## Things that look true and are not + +- **A comment in a `.props` file** describing a default the next line no longer sets. +- **The same table in two places,** both stale. Fix both from the code, then delete one and link to + the other where the tests allow it. +- **A recipe name in a doc** from before the `Justfile` was split into modules: check + `just --list`, not memory. +- **An example `global.json` or package version** — usually a pin the version-sync check owns; leave + the number to it. +- **"Removed" features** still described in a page nobody re-read when they went. Search for the + feature's property names as well as its title. diff --git a/.agents/skills/purview-buildsdk-docs-sync/references/wiki-structure.md b/.agents/skills/purview-buildsdk-docs-sync/references/wiki-structure.md new file mode 100644 index 00000000..a4b7c534 --- /dev/null +++ b/.agents/skills/purview-buildsdk-docs-sync/references/wiki-structure.md @@ -0,0 +1,109 @@ +# The wiki structure + +`docs/wiki/` is the source for the project's pages on the documentation site. The site's sync reads +the folder, publishes each page at a URL derived from its path, and rewrites relative `.md` links into +site routes. That makes the folder tree part of the published interface, not just filing. + +## Layout + +```text +docs/wiki/ + _Sidebar.md page order and sections (not published) + Home.md the browsing index (not published) + Getting-Started.md the landing page (docs.rootPage) + .md any page the site catalogue deep-links (docsPage) + / + .md + / + ... +``` + +- **Root pages need a site contract.** Only the landing page and the pages the site's project + catalogue deep-links through `docsPage` stay at the root. A deep link is a single path segment, so + such a page cannot live in a folder unless the catalogue entry moves with it. Find the contract in + the site's catalogue entry for the project (`docs.rootPage`, every `useCases[].docsPage`). +- **One level of folders.** A page is at the root or in exactly one topic folder. A topic folder is + named for the area (`configuration/`, `packaging/`), lower case, and holds at least two pages unless + the area is clearly going to grow. Do not name a folder `build/` — the standard .NET `.gitignore` + ignores `[Bb]uild/`. +- **URL-safe names.** Folder and page names are letters, digits and single dashes. The path becomes + the slug, lower-cased: `packaging/SourceLink.md` → `/docs//packaging/sourcelink/`. +- **One title.** Each page opens with exactly one `# ` heading — the title the site shows — and a + one-paragraph description of what the page answers. Section headings are `##` and below. +- **Relative links, from the file they are in.** From `configuration/Property-Schema.md`, the reference + page is `../Configuration-Reference.md` and a sibling is `Version-Detection.md`. A link that climbs + out of `docs/wiki/` (`../../README.md`) is a repository path; the site rewrites it to a GitHub URL. + +## `_Sidebar.md` + +```markdown + + +- [Home](Home.md) +- **Get started** + - [Getting Started](Getting-Started.md) + - [Engineering Principles](concepts/Engineering-Principles.md) +- **Configuration** + - [Configuration Reference](Configuration-Reference.md) + - [Property Schema](configuration/Property-Schema.md) +``` + +Every page appears exactly once, with its path from `docs/wiki/`. A page missing here is published +with no place in the sidebar. A page listed twice has two competing positions. + +## `Home.md` + +```markdown +# Wiki + + + +## How this wiki is organised + +| Section | Where it lives | +| -- | -- | +| Get started | the wiki root and `concepts/` | +| Configuration | `Configuration-Reference.md` and `configuration/` | + +## Get started + +| Page | What it answers | +| -- | -- | +| [Getting Started](Getting-Started.md) | Installing the SDK and building a first project. | +``` + +The `## ` headings are the sidebar's section labels, word for word, in the same order. Every page is +linked from at least one section table. Feature highlights and requirements may follow the sections. + +## Moving or renaming a page + +The published URL changes, and inbound links — from other repositories, from search results, +from bookmarks — break. So: + +1. Move it with `git mv`, so history follows the file. +2. Fix every inbound link: other wiki pages (relative to *their* folder), `_Sidebar.md`, `Home.md`, the + READMEs, `AGENTS.md`, the skills, and any test that names the path. +3. Record `old-slug: new/slug` pairs (lower-cased, no `.md`) and ask the user to add them to the site + catalogue's `docs.redirects`, which keeps the old URL working. Editing the site's repository is + outside this repository — do not do it unasked. + +## The test + +A repository with a wiki should hold it to these rules in its test suite, reading files only, with +no network: + +| Assertion | Catches | +| -- | -- | +| the wiki folder resolves to this repository (`_Sidebar.md`, `Home.md` and the landing page exist) | every other assertion passing vacuously | +| every page is at the root or one folder deep, and the folders are exactly the expected set | accidental nesting, a stray new topic | +| root pages are exactly the site-contract pages | a page left at the root, or a deep-linked page moved | +| every folder and page name is URL-safe | a slug the sync would rewrite | +| `_Sidebar.md` lists every page exactly once | an unpublished or doubly-ordered page | +| `Home.md` links every page | a page no reader can browse to | +| every sidebar section has a matching `## ` in `Home.md` | the two indexes disagreeing | +| every relative `.md` link in the wiki resolves | a page moved without its inbound links | +| every page opens with exactly one `# ` title (code fences and inline code ignored) | untitled or doubly-titled pages | + +Strip fenced code blocks and inline code before reading headings or links. A quoted `# comment` in a +shell example is not a title. diff --git a/.agents/skills/purview-buildsdk-justfile/SKILL.md b/.agents/skills/purview-buildsdk-justfile/SKILL.md new file mode 100644 index 00000000..0ac76c2a --- /dev/null +++ b/.agents/skills/purview-buildsdk-justfile/SKILL.md @@ -0,0 +1,185 @@ +--- +name: purview-buildsdk-justfile +description: Set up or update the Justfile of a repository that uses Purview.BuildSdk - a root Justfile that imports one barrel, .build/just/index.just, over shared modules for .NET (restore, build, test, pack, ensure-tools), version display and `just bump major|minor|patch`, local Purview.Build pipeline runs (pipeline-pr, pipeline-local-release), the agent-skill targets (skills-add, skills-install, skills-update, skills-check, skills-fix), lint-check/lint-fix, vs, scrub, an optional pm-docs-site Docs group, and exported telemetry opt-outs and .NET CLI speed-ups. Use when adding a Justfile, splitting or de-duplicating one, adding a just recipe, bumping the version from just, replacing hand-written skill scripts (update-agent-skills.ts, package.json skills:* scripts) with the SDK targets, or when a recipe runs the wrong project, runner or agents. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# The Justfile + +A repository's recipes are mostly the same everywhere: restore, build, test, pack, lint, the +pipeline, the skill targets. Written into each `Justfile` by hand they drift, and every repository +fixes the same quoting bug separately. So the shared recipes are **modules**, copied verbatim from +this skill's [`assets/just/`](assets/just/index.just) into the repository, and the root `Justfile` +holds only what is specific to that repository. + +```text +Justfile settings, one import, the repository's variables and own recipes +.build/just/ + index.just the barrel: imports every module, optional ones with import? + env.just exported telemetry opt-outs and .NET CLI speed-ups + version.just version, bump + bump-version.ts what `just bump` runs (Bun) + dotnet.just ensure-tools, restore, build, clean, test, pack + utilities.just lint-check, lint-fix, vs, scrub + purview-build.just pipeline-pr, pipeline-build, pipeline-tests + skills.just skills-add, -install, -update, -check, -fix + purview-build-nuget.just optional: pipeline-pack-validate, pipeline-local-release, pipeline-release + docs.just optional: docs, docs-install, docs-check +``` + +`.build/` is not matched by the stock `.gitignore` templates — `[Bb]uild/` does not match a leading +dot — but confirm it with `git check-ignore -v .build/just/index.just` (no output, exit 1, means +tracked). If a rule matches, add `!/.build/` after it. + +## The root Justfile + +```just +# Recipes for . Run `just` for the list. +# +# The shared recipes come from .build/just/, copied verbatim from the purview-buildsdk-justfile +# skill. Change them there, not here; this file holds what belongs to this repository. + +set quiet + +# Let a definition here override a module's default variable or no-op hook. +set allow-duplicate-variables +set allow-duplicate-recipes + +import ".build/just/index.just" + +solution_file := "src/Acme.slnx" + +[private] +default: + just --list +``` + +**Import the barrel and nothing else.** Adding or removing a module never touches the root file. + +Both `allow-duplicate-*` settings are required. Every module gives its variables a default, and a +definition in the root `Justfile` replaces it — regardless of where it sits relative to the import. +Without the settings, a duplicate is an error. + +## Variables the modules read + +Override only what differs from the default. `solution_file` is the one nearly every repository sets. + +| Variable | Module | Default | Notes | +| -- | -- | -- | -- | +| `solution_file` | `dotnet.just` | `.` | the `.slnx`/`.sln` every recipe builds; `.` lets dotnet find the only one in the root | +| `build_configuration` | `dotnet.just` | `Debug` | | +| `artifacts_folder` | `dotnet.just` | `./artifacts` | `pack` output; `scrub` deletes it | +| `test_filter` | `dotnet.just` | `/*/*/*/*/` | MTP tree-node filter, `just test` with no argument | +| `version_file` | `version.just` | `package.json` | read by just itself, no Bun needed | +| `current_version` | `version.just` | from `version_file` | shown by `build`, `pack`, `version` | +| `csharpier` | `utilities.just` | `dotnet csharpier` | | +| `pipeline_tool` | `purview-build.just` | `dotnet purview-build` | | +| `skills_project` | `skills.just` | `solution_file` | always the solution — see [the skill recipes](references/skills-recipes.md) | +| `docs_site` | `docs.just` | `src/docs-site` | pm-docs-site's `SITE_ROOT` | + +To find `solution_file`: an existing `Justfile` variable or the path its `build`/`test` recipes use; +otherwise the only `*.slnx`/`*.sln` outside `bin/`, `obj/`, `node_modules/` and `artifacts/`; with +several, the one CI builds. Ask only if that still leaves more than one. + +## Hooks + +`_after-bump` runs after a successful `just bump` and does nothing by default. A repository that +pins its version anywhere else — docs, templates — overrides it in the root `Justfile`: + +```just +_after-bump: version-sync +``` + +## Optional modules + +The barrel imports these with `import?`, so **the file's presence is the switch**: copy it into +`.build/just/` to turn it on, delete it to turn it off. Never edit `index.just` for this. + +- **`purview-build-nuget.just`** — copy it when the repository packs NuGet packages: any project is + packable, or `.config/purview-build.json` has a `PackValidation` section. Pack validation reads + `PackValidation.RequiredContent`, which lists every file each package must contain. A packed file + that it does not list fails the run (`contains undeclared content ... not defined in + RequiredContent`), so a new file under a package's `Sdk/`, `build/` or `buildTransitive/` needs an + entry there as well. +- **`docs.just`** — copy it when the repository has a pm-docs-site project site: a `pm-docs-site.json` + somewhere, usually `src/docs-site/`. Set `docs_site` to that folder when it is not `src/docs-site`. + This module **is** the `Docs` group pm-docs-site's spec asks for, so do not add a second one to the + root `Justfile`. + +## The environment + +`env.just` exports, to every process a recipe starts: + +- **Telemetry opt-outs**: the .NET CLI, MSBuild, Aspire, the Azure SDKs and Azure MCP, PowerShell, + Git Credential Manager, Next.js, Astro, Yarn, Claude Code, plus `DO_NOT_TRACK`. +- **.NET CLI speed and noise**: `DOTNET_CLI_ENABLEAOT` (the .NET 11 NativeAOT CLI host) and + `DOTNET_NOLOGO`. + +The MSBuild server (`DOTNET_CLI_USE_MSBUILD_SERVER`) is deliberately **not** forced on. One shared, +long-lived MSBuild process keeps the NuGet SDK resolver's settings from the first build. A later build +under a different `nuget.config` then resolves with the wrong sources and fallback folders, and fails +with `MSB4242`. Purview.BuildSdk's own package-consumption tests do exactly that, and fail. Leave it to +the SDK default unless every build on the machine shares one NuGet configuration. + +Each is `env(NAME, default)`, so a value already in the environment — a developer who opted back in, a +CI job — wins. Multithreaded MSBuild (`-mt`) is not set here: Purview.BuildSdk bootstraps it into +`Directory.Build.rsp`, which a plain `dotnet build` and an IDE also read. + +## Version + +`just version` prints the version from `package.json`. `just bump major|minor|patch` increments that +part and resets the parts after it — `2.0.1` → `3.0.0` / `2.1.0` / `2.0.2` — dropping any +pre-release suffix. It rewrites only the `"version"` value, so the file keeps its formatting. +`just bump patch --dry-run` prints the result and writes nothing. Any other part is rejected before +anything runs. It needs Bun. + +## Adopting it, or migrating an existing Justfile + +1. **Read the whole existing `Justfile`**, any `*.just` it imports, CI workflows, and hook config + (`.config/lefthook.yml`, `lefthook.yml`, `.husky/`). Write down every recipe name something outside + the `Justfile` calls. +2. **Copy the modules** from [`assets/just/`](assets/just/index.just) into `.build/just/`, byte for + byte, plus the optional ones that apply. Do not edit them in place. +3. **Write the root `Justfile`** as above, with the variables the repository needs. +4. **Delete every recipe a module now provides.** A copy in the root silently *replaces* the module's + recipe (that is what `allow-duplicate-recipes` does), so a leftover duplicate is not harmless: it + keeps the old behaviour and hides the fix. Keep a root recipe only when it does something + different on purpose, and comment why. +5. **Keep what is the repository's own**: recipes that run its own scripts, apps or migrations. Put + them in the root `Justfile` under their own `[group(...)]`. +6. **Map renamed recipes** and update every caller found in step 1: + + | Old | Now | + | -- | -- | + | `ensure-pipeline-tool`, `restore-tools` | `ensure-tools` | + | `current_version` (recipe) | `version` | + | `import "Justfile.skills.just"` | `skills.just`, through the barrel — delete the old file | + | a hand-written `Docs` group | `docs.just` | + +7. **Do not add `set shell` or `set windows-shell`.** The modules are written for just's default `sh` + — Git Bash on Windows — so they run unchanged on every OS. + +## Changing a module + +Change it here, in the skill's `assets/just/`, then copy the file into each repository. A repository +that edits its copy drifts from every other, and the next copy overwrites the edit. If one repository +needs different behaviour, override the recipe or variable in its root `Justfile`. + +Recipe comments: just shows a recipe's **last** comment line in `just --list`. A recipe with a +multi-line comment carries `[doc('...')]` with its one-line summary. + +## Verify + +```bash +git check-ignore -v .build/just/index.just # no output: tracked +just --list # every group, one-line descriptions +just --dry-run build # the solution path is the right one +just bump patch --dry-run # prints the next version, writes nothing +just skills-update -p:PurviewSkillsDryRun=true +``` + +For the skill recipes in detail, replacing hand-written skill scripts, and the runner and agent +settings, see [references/skills-recipes.md](references/skills-recipes.md). diff --git a/.agents/skills/purview-buildsdk-justfile/assets/just/bump-version.ts b/.agents/skills/purview-buildsdk-justfile/assets/just/bump-version.ts new file mode 100644 index 00000000..8cb38ffb --- /dev/null +++ b/.agents/skills/purview-buildsdk-justfile/assets/just/bump-version.ts @@ -0,0 +1,62 @@ +// Bumps one part of the semantic version in package.json, for `just bump `. +// +// The parts after the one bumped are reset to 0, and a pre-release or build suffix is dropped: +// +// 2.0.1 major -> 3.0.0 minor -> 2.1.0 patch -> 2.0.2 +// 2.1.0-rc.1 patch -> 2.1.1 +// +// Only the "version" value is rewritten, in place, so the file keeps its own indentation, key order +// and line endings - re-serialising the JSON would reformat it. +// +// bun bump-version.ts [package.json] [--dry-run] + +import { readFileSync, writeFileSync } from "node:fs"; + +const parts = ["major", "minor", "patch"] as const; +type Part = (typeof parts)[number]; + +const args = process.argv.slice(2); +const dryRun = args.includes("--dry-run"); +const [part, file = "package.json"] = args.filter((arg) => arg !== "--dry-run"); + +if (!parts.includes(part as Part)) { + console.error(`Expected one of ${parts.join(", ")}, not '${part ?? ""}'.`); + process.exit(1); +} + +const text = readFileSync(file, "utf8"); +const versionPattern = /("version"\s*:\s*")([^"]*)(")/; +const match = versionPattern.exec(text); +if (!match) { + console.error(`No "version" field in ${file}.`); + process.exit(1); +} + +const current = match[2]; +const semver = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(current); +if (!semver) { + console.error(`'${current}' in ${file} is not a major.minor.patch version.`); + process.exit(1); +} + +let [major, minor, patch] = semver.slice(1, 4).map(Number); +switch (part as Part) { + case "major": + [major, minor, patch] = [major + 1, 0, 0]; + break; + case "minor": + [minor, patch] = [minor + 1, 0]; + break; + case "patch": + patch += 1; + break; +} + +const next = `${major}.${minor}.${patch}`; +if (dryRun) { + console.log(` ${file}: ${current} -> ${next} (dry run, nothing written)`); + process.exit(0); +} + +writeFileSync(file, text.replace(versionPattern, `$1${next}$3`)); +console.log(` ${file}: ${current} -> ${next}`); diff --git a/.agents/skills/purview-buildsdk-justfile/assets/just/docs.just b/.agents/skills/purview-buildsdk-justfile/assets/just/docs.just new file mode 100644 index 00000000..646d444a --- /dev/null +++ b/.agents/skills/purview-buildsdk-justfile/assets/just/docs.just @@ -0,0 +1,26 @@ +# Optional: the project site a pm-docs-site skill (purview-dev/pm-skills) scaffolds. Copy this file +# into the module folder only when the repository has one; the barrel imports it if present. Each +# recipe runs inside the site, so none of them needs `cd`. Requires Bun. + +# The site folder, relative to the repository root - pm-docs-site's SITE_ROOT. +docs_site := "src/docs-site" + +# Serve the project site at http://localhost:4321 +[group('Docs')] +[working-directory(docs_site)] +docs: docs-install + bun run dev + +# Install the project site's dependencies from its lockfile +[group('Docs')] +[working-directory(docs_site)] +docs-install: + bun install --frozen-lockfile + +# Test, type-check and build the project site - its build check +[group('Docs')] +[working-directory(docs_site)] +docs-check: docs-install + bun run test + bun run check + bun run build diff --git a/.agents/skills/purview-buildsdk-justfile/assets/just/dotnet.just b/.agents/skills/purview-buildsdk-justfile/assets/just/dotnet.just new file mode 100644 index 00000000..9507263f --- /dev/null +++ b/.agents/skills/purview-buildsdk-justfile/assets/just/dotnet.just @@ -0,0 +1,53 @@ +# .NET recipes: tools, restore, build, clean, test and pack, all against one solution. +# +# Override these in the root Justfile. `solution_file` is the one nearly every repository sets; "." +# lets dotnet find the single solution or project in the repository root. + +solution_file := "." +build_configuration := "Debug" +artifacts_folder := "./artifacts" + +# Every test. A TUnit/MTP tree-node filter: /assembly/namespace/class/test. +test_filter := "/*/*/*/*/" + +# Restore the repository's .NET local tools (.config/dotnet-tools.json) +[group('Build and Test')] +ensure-tools: + dotnet tool restore + +# Restore NuGet packages for the solution +[group('Build and Test')] +restore *args: + echo "==> Restoring {{ BLUE }}{{ solution_file }}{{ NORMAL }}" + dotnet restore "{{ solution_file }}" {{ args }} + +# Build the solution with build_configuration (default: Debug) +[group('Build and Test')] +build *args: + echo "==> Building {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ GREEN }}{{ current_version }}{{ NORMAL }}) with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}" + dotnet build "{{ solution_file }}" --configuration "{{ build_configuration }}" {{ args }} + +# Clean the solution with build_configuration (default: Debug) +[group('Build and Test')] +clean *args: + echo "==> Cleaning {{ BLUE }}{{ solution_file }}{{ NORMAL }} with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}" + dotnet clean "{{ solution_file }}" --configuration "{{ build_configuration }}" {{ args }} + +# Run the tests matching a tree-node filter (default: every test) +# +# just test "/*/*/*/*[Category=Unit]" +# just test "/*/*/ProjectHarnessTests/*" +# +# Exit code 8 - "no test ran" - is ignored, so a filter that matches nothing in one test project does +# not fail the run for the others. +[doc('Run the tests matching a tree-node filter (default: every test)')] +[group('Build and Test')] +test filter=test_filter *args: + echo "==> Testing {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ YELLOW }}{{ build_configuration }}{{ NORMAL }}) with filter {{ GREEN }}{{ filter }}{{ NORMAL }}" + dotnet test --project "{{ solution_file }}" --configuration "{{ build_configuration }}" --treenode-filter "{{ filter }}" --ignore-exit-code 8 {{ args }} + +# Pack every packable project into artifacts_folder (default: ./artifacts) +[group('Build and Test')] +pack artifact_folder=artifacts_folder *args: + echo "==> Packing {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ GREEN }}{{ current_version }}{{ NORMAL }}) to {{ YELLOW }}{{ artifact_folder }}{{ NORMAL }}" + dotnet pack "{{ solution_file }}" --configuration "{{ build_configuration }}" --output "{{ artifact_folder }}" {{ args }} diff --git a/.agents/skills/purview-buildsdk-justfile/assets/just/env.just b/.agents/skills/purview-buildsdk-justfile/assets/just/env.just new file mode 100644 index 00000000..6156f34b --- /dev/null +++ b/.agents/skills/purview-buildsdk-justfile/assets/just/env.just @@ -0,0 +1,56 @@ +# The environment every recipe runs in: telemetry opt-outs, and the .NET CLI speed-ups. +# +# `export` hands each value to every process a recipe starts - dotnet, bun, pwsh, git. Each one is a +# default: `env(NAME, value)` keeps whatever the caller's environment already says, so a developer +# who has opted back in, or a CI job that sets its own value, is never overridden. + +# -- Telemetry and tracking -------------------------------------------------------------------------- + +# The .NET CLI, MSBuild and Aspire. +export DOTNET_CLI_TELEMETRY_OPTOUT := env("DOTNET_CLI_TELEMETRY_OPTOUT", "1") +export MSBUILD_TELEMETRY_OPTOUT := env("MSBUILD_TELEMETRY_OPTOUT", "1") +export ASPIRE_CLI_TELEMETRY_OPTOUT := env("ASPIRE_CLI_TELEMETRY_OPTOUT", "true") +export ASPIRE_DASHBOARD_TELEMETRY_OPTOUT := env("ASPIRE_DASHBOARD_TELEMETRY_OPTOUT", "true") + +# The Azure SDKs and the Azure MCP server. +export AZURE_CORE_COLLECT_TELEMETRY := env("AZURE_CORE_COLLECT_TELEMETRY", "0") +export AZURE_MCP_COLLECT_TELEMETRY := env("AZURE_MCP_COLLECT_TELEMETRY", "0") + +# PowerShell, for scripts a recipe runs through pwsh, and Git Credential Manager. +export POWERSHELL_TELEMETRY_OPTOUT := env("POWERSHELL_TELEMETRY_OPTOUT", "1") +export GCM_TELEMETRY_OPTOUT := env("GCM_TELEMETRY_OPTOUT", "1") + +# JavaScript tooling: Next.js, Astro, Yarn, and the funding banners printed by postinstall scripts. +export NEXT_TELEMETRY_DISABLED := env("NEXT_TELEMETRY_DISABLED", "1") +export ASTRO_TELEMETRY_DISABLED := env("ASTRO_TELEMETRY_DISABLED", "1") +export YARN_ENABLE_TELEMETRY := env("YARN_ENABLE_TELEMETRY", "0") +export DISABLE_OPENCOLLECTIVE := env("DISABLE_OPENCOLLECTIVE", "true") + +# Claude Code, when a recipe launches it. +export CLAUDE_CLI_TELEMETRY_OPTOUT := env("CLAUDE_CLI_TELEMETRY_OPTOUT", "1") +export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC := env("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") +export DISABLE_ERROR_REPORTING := env("DISABLE_ERROR_REPORTING", "1") +export DISABLE_TELEMETRY := env("DISABLE_TELEMETRY", "1") + +# The cross-tool conventions, honoured by Bun and many other CLIs. +export DO_NOT_TRACK := env("DO_NOT_TRACK", "1") +export TELEMETRY_DISABLED := env("TELEMETRY_DISABLED", "1") + +# -- .NET CLI speed and noise ------------------------------------------------------------------------ + +# No welcome banner or first-run notice. +export DOTNET_NOLOGO := env("DOTNET_NOLOGO", "1") + +# The NativeAOT CLI host (.NET 11): near-instant `dotnet --version`, `--help` and tool launches, with +# everything else falling back to the managed CLI. On by default; opt out with DOTNET_CLI_ENABLEAOT=false. +export DOTNET_CLI_ENABLEAOT := env("DOTNET_CLI_ENABLEAOT", "true") + +# The MSBuild server (DOTNET_CLI_USE_MSBUILD_SERVER) is deliberately left to the SDK's own default. +# Forcing it on makes every `dotnet build` share one long-lived MSBuild process, and its NuGet SDK +# resolver keeps the settings it first read: a build under a different nuget.config - a test +# harness's throwaway consumer, a second repository - then resolves SDKs with the wrong sources and +# fallback folders, and fails with MSB4242. Set it yourself where every build shares one NuGet +# configuration; `just scrub` shuts the server down. + +# Multithreaded MSBuild (-mt) is deliberately not set here. Purview.BuildSdk bootstraps it into the +# repository's Directory.Build.rsp, so a plain `dotnet build` and an IDE build get it as well. diff --git a/.agents/skills/purview-buildsdk-justfile/assets/just/index.just b/.agents/skills/purview-buildsdk-justfile/assets/just/index.just new file mode 100644 index 00000000..1d73bbe6 --- /dev/null +++ b/.agents/skills/purview-buildsdk-justfile/assets/just/index.just @@ -0,0 +1,41 @@ +# The barrel for the shared just modules. The root Justfile imports this file, and nothing else: +# +# set allow-duplicate-variables +# set allow-duplicate-recipes +# +# import ".build/just/index.just" +# +# Every module gives its variables a default. The root Justfile overrides one by defining the same +# name - `solution_file := "src/Acme.slnx"` - and overrides a no-op hook such as `_after-bump` the +# same way. Both settings are what let a root definition win; without them a duplicate is an error. +# +# The required modules are imported unconditionally. The optional ones use `import?`, so a module is +# switched on by copying its file into this folder and off by deleting it: this file never changes. +# +# These files are copied verbatim from the purview-buildsdk-justfile skill. Change a recipe there, +# not here, and put anything specific to one repository in its root Justfile. + +# Telemetry opt-outs and the .NET CLI speed-ups, exported to every recipe. +import "env.just" + +# version, bump +import "version.just" + +# ensure-tools, restore, build, clean, test, pack +import "dotnet.just" + +# lint-check, lint-fix, vs, scrub +import "utilities.just" + +# pipeline-pr, pipeline-build, pipeline-tests - local runs of the Purview.Build pipeline +import "purview-build.just" + +# skills-* - the Purview.BuildSdk agent-skill targets +import "skills.just" + +# Optional. pipeline-pack-validate, pipeline-local-release, pipeline-release - for a repository that +# packs NuGet packages. +import? "purview-build-nuget.just" + +# Optional. docs, docs-install, docs-check - for a pm-docs-site project site (purview-dev/pm-skills). +import? "docs.just" diff --git a/.agents/skills/purview-buildsdk-justfile/assets/just/purview-build-nuget.just b/.agents/skills/purview-buildsdk-justfile/assets/just/purview-build-nuget.just new file mode 100644 index 00000000..21548760 --- /dev/null +++ b/.agents/skills/purview-buildsdk-justfile/assets/just/purview-build-nuget.just @@ -0,0 +1,30 @@ +# Optional: Purview.Build pipeline runs that pack, validate and publish NuGet packages. Copy this file +# into the module folder only when the repository packs packages; the barrel imports it if present. +# +# Pack validation reads PackValidation in .config/purview-build.json. Its RequiredContent lists every +# file each package must contain, and a packed file it does not list fails the run - so a new file +# under a package's Sdk/, build/ or buildTransitive/ needs a RequiredContent entry too. + +# Run the pipeline through pack and pack validation (restore, build, lint, tests, pack, validate), publishing nothing +[group('Pipeline')] +pipeline-pack-validate *args: ensure-tools + echo "==> Running the pack and validate pipeline" + {{ pipeline_tool }} --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} + +# Run the release pipeline, publishing to a local NuGet feed (restore, build, lint, tests, pack, validate, publish) +# +# `just` runs recipes through sh, which strips backslashes from unquoted arguments. Set the +# LOCAL_NUGET_FEED_PATH environment variable, or use forward slashes: +# +# just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ +[doc('Run the release pipeline, publishing to a local NuGet feed (restore, build, lint, tests, pack, validate, publish)')] +[group('Pipeline')] +pipeline-local-release *args: ensure-tools + echo "==> Running the local release pipeline" + {{ pipeline_tool }} --Release:Mode=LocalNuGet {{ args }} + +# Run the release pipeline, publishing to NuGet (restore, build, lint, tests, pack, validate, publish, GitHub release) +[group('Pipeline')] +pipeline-release *args: ensure-tools + echo "==> Running the release pipeline" + {{ pipeline_tool }} --Release:Mode=NuGet {{ args }} diff --git a/.agents/skills/purview-buildsdk-justfile/assets/just/purview-build.just b/.agents/skills/purview-buildsdk-justfile/assets/just/purview-build.just new file mode 100644 index 00000000..244e44cd --- /dev/null +++ b/.agents/skills/purview-buildsdk-justfile/assets/just/purview-build.just @@ -0,0 +1,24 @@ +# Local runs of the Purview.Build pipeline - the same `dotnet purview-build` tool, and the same +# .config/purview-build.json, that CI runs. Every Build stage - tests, lint, pack and pack validation - +# runs unless that file or an argument turns it off. Anything after the recipe name is passed to the +# tool, so a setting can be overridden for one run: `just pipeline-pr --Build:TestFilter="/*/*/*/*[Category=Unit]"`. + +pipeline_tool := "dotnet purview-build" + +# Run the PR pipeline (restore, build, lint, tests, pack, validate), as configured +[group('Pipeline')] +pipeline-pr *args: ensure-tools + echo "==> Running the PR pipeline" + {{ pipeline_tool }} {{ args }} + +# Run the build pipeline (restore, build, lint, pack, validate), without tests +[group('Pipeline')] +pipeline-build *args: ensure-tools + echo "==> Running the build pipeline" + {{ pipeline_tool }} --Build:RunTests=false --Release:Mode=None {{ args }} + +# Run the pipeline with tests forced on and release off (restore, build, lint, tests, pack, validate) +[group('Pipeline')] +pipeline-tests *args: ensure-tools + echo "==> Running the tests pipeline" + {{ pipeline_tool }} --Build:RunTests=true --Release:Mode=None {{ args }} diff --git a/.agents/skills/purview-buildsdk-justfile/assets/just/skills.just b/.agents/skills/purview-buildsdk-justfile/assets/just/skills.just new file mode 100644 index 00000000..0d339023 --- /dev/null +++ b/.agents/skills/purview-buildsdk-justfile/assets/just/skills.just @@ -0,0 +1,52 @@ +# The Purview.BuildSdk agent-skill targets. Each recipe names one target; the behaviour lives there. +# +# The targets are a thin wrapper over the standard skills CLI (`bun x skills` by default), run from the +# repository root. Every skill target runs once for the whole solution, however many projects it holds, +# so the recipes name the solution, not a hand-picked project. +# +# Every recipe here is run by a person, on purpose. Never make one a dependency of build, test or +# default, or call skills-add, -install, -update or -fix from a git hook: they change the repository, +# and the SDK refuses to run them inside a build (PRSKL0009). skills-check is read-only. +# +# The runner and agents are MSBuild properties (PurviewSkillsRunner, PurviewSkillsAgents), set once in +# Directory.Build.props. Pass a one-off through *args; `dotnet msbuild -p:` splits on `,` and `;`, so +# escape the separator: `just skills-update -p:PurviewSkillsAgents=claude-code%2Ccodex`. + +skills_project := solution_file + +# Install skills from another repository through the skills CLI (append '#' to pin it) +# +# just skills-add purview-dev/engineering-playbook +[doc("Install skills from another repository through the skills CLI (append '#' to pin it)")] +[group('Agent Skills')] +skills-add source *args: + echo "==> Installing skills from {{ BLUE }}{{ source }}{{ NORMAL }}" + dotnet msbuild "{{ skills_project }}" -t:InstallPurviewSkills -p:PurviewSkillsSource={{ source }} -v:n {{ args }} + +# Restore every skill skills-lock.json records - how a fresh clone gets the repository's skills back +[group('Agent Skills')] +skills-install *args: + dotnet msbuild "{{ skills_project }}" -t:InstallPurviewSkills -v:n {{ args }} + +# Update the agent skills by re-adding every unique source in skills-lock.json +# +# Each source is re-added at its latest with every skill it has, which refreshes the installed skills +# and adds any a source has gained; `skills update` is never run. FixPurviewSkills then removes links +# from agent directories PurviewSkillsAgents does not select, and does nothing when it is unset. +# The -t: value is quoted because sh would otherwise end the command at the `;`. +# +# just skills-update -p:PurviewSkillsDryRun=true +[doc('Update the agent skills by re-adding every unique source in skills-lock.json')] +[group('Agent Skills')] +skills-update *args: + dotnet msbuild "{{ skills_project }}" "-t:UpdatePurviewSkills;FixPurviewSkills" -v:n {{ args }} + +# Check that only the skill directories of the agents in PurviewSkillsAgents exist +[group('Agent Skills')] +skills-check *args: + dotnet msbuild "{{ skills_project }}" -t:CheckPurviewSkills -v:n {{ args }} + +# Remove the skills CLI's links from agent directories PurviewSkillsAgents does not select +[group('Agent Skills')] +skills-fix *args: + dotnet msbuild "{{ skills_project }}" -t:FixPurviewSkills -v:n {{ args }} diff --git a/.agents/skills/purview-buildsdk-justfile/assets/just/utilities.just b/.agents/skills/purview-buildsdk-justfile/assets/just/utilities.just new file mode 100644 index 00000000..d813497b --- /dev/null +++ b/.agents/skills/purview-buildsdk-justfile/assets/just/utilities.just @@ -0,0 +1,44 @@ +# Formatting, opening the solution, and scrubbing the build output. + +csharpier := "dotnet csharpier" + +# Check formatting with CSharpier +[group('Utilities')] +lint-check: + {{ csharpier }} check . + +# Format the code with CSharpier +[group('Utilities')] +lint-fix: + {{ csharpier }} format . + +# Open the solution in Visual Studio, or whichever application is registered for it +[group('Utilities')] +[windows] +vs: + start "" "{{ solution_file }}" + +# Open the solution in whichever application is registered for it +[group('Utilities')] +[macos] +vs: + open "{{ solution_file }}" + +# Open the solution in whichever application is registered for it +[group('Utilities')] +[linux] +vs: + xdg-open "{{ solution_file }}" + +# Delete every bin/, obj/ and artifacts_folder, stop the build servers, and restore from scratch +# +# The build servers go first: the MSBuild server and the compiler server hold files open under obj/, +# which on Windows stops them being deleted. node_modules/ and .git/ are never searched. +[doc('Delete every bin/, obj/ and artifacts_folder, stop the build servers, and restore from scratch')] +[group('Utilities')] +scrub: + echo "==> Scrubbing build output and stopping the build servers" + dotnet build-server shutdown + find . \( -name node_modules -o -name .git \) -prune -o -type d \( -name bin -o -name obj \) -prune -exec rm -rf {} + + rm -rf "{{ artifacts_folder }}" + just restore --force-evaluate diff --git a/.agents/skills/purview-buildsdk-justfile/assets/just/version.just b/.agents/skills/purview-buildsdk-justfile/assets/just/version.just new file mode 100644 index 00000000..09dbb375 --- /dev/null +++ b/.agents/skills/purview-buildsdk-justfile/assets/just/version.just @@ -0,0 +1,28 @@ +# The package version, from package.json: the single source of truth Purview.BuildSdk versions every +# project and package from. + +version_file := "package.json" + +# Read by just itself rather than by spawning bun, so `just --list` needs no Node tooling. +current_version := if path_exists(version_file) == "true" { replace_regex(read(version_file), '(?s).*?"version"\s*:\s*"([^"]*)".*', '$1') } else { "0.0.0" } + +# Display the current version, from package.json +[group('Version')] +version: + echo "==> Current version: {{ GREEN }}{{ current_version }}{{ NORMAL }} (from {{ version_file }}, stamped into every build by Purview.BuildSdk)" + +# Bump one part of the version in package.json - major, minor or patch - resetting the parts after it +# +# just bump minor # 2.0.1 -> 2.1.0 +# just bump patch --dry-run # print the new version, write nothing +# +# A pre-release suffix is dropped. Requires Bun. Afterwards it runs `_after-bump`, which does nothing +# unless the root Justfile overrides it to refresh whatever else pins the version. +[doc('Bump one part of the version in package.json - major, minor or patch - resetting the parts after it')] +[arg("part", pattern="major|minor|patch")] +[group('Version')] +bump part *args: && _after-bump + bun "{{ source_directory() / 'bump-version.ts' }}" "{{ part }}" "{{ version_file }}" {{ args }} + +# Runs after a successful `bump`. Override it in the root Justfile, e.g. `_after-bump: version-sync`. +_after-bump: diff --git a/.agents/skills/purview-buildsdk-justfile/references/skills-recipes.md b/.agents/skills/purview-buildsdk-justfile/references/skills-recipes.md new file mode 100644 index 00000000..5c850f5f --- /dev/null +++ b/.agents/skills/purview-buildsdk-justfile/references/skills-recipes.md @@ -0,0 +1,100 @@ +# The agent-skill recipes + +`skills.just` gives each SDK skill target a recipe. The targets are a thin wrapper over the standard +skills CLI (`bun x skills` by default), which finds a repository's skills in its root `skills/` +folder and records what it installed in `skills-lock.json`. Their behaviour is covered by +`purview-buildsdk-skill-installation`; this page is about the recipes that run them. + +## The rule that matters most + +**Every skill recipe is run by a person, on purpose.** `InstallPurviewSkills`, `UpdatePurviewSkills` +and `FixPurviewSkills` change the repository, and the SDK refuses to run them inside a build, a +restore or a design-time build (`PRSKL0009`). Keep the `Justfile` consistent with that: + +- never make a skills recipe a dependency of `build`, `test`, `default` or any other recipe; +- never call `skills-add`, `skills-install`, `skills-update` or `skills-fix` from a git hook + (`lefthook.yml`, `.husky/`) or from a CI job that commits — `PRSKL0009` cannot see those, because they + run the target on its own; +- `skills-check` is read-only and safe anywhere. + +## The recipes + +| Recipe | Target | Runs | Changes the repository | +| -- | -- | -- | -- | +| `skills-add ` | `InstallPurviewSkills` with `PurviewSkillsSource` | `skills add --skill "*"` | yes | +| `skills-install` | `InstallPurviewSkills` | `skills experimental_install` — restore `skills-lock.json` | yes | +| `skills-update` | `UpdatePurviewSkills;FixPurviewSkills` | `skills add --skill "*"` once per lockfile source | yes | +| `skills-check` | `CheckPurviewSkills` | nothing | no | +| `skills-fix` | `FixPurviewSkills` | nothing; removes links itself | yes | + +A repository that *authors* skills usually adds `skills-validate` to its root `Justfile`. It runs +`ValidatePurviewSkills` against the one project that packs, because that target runs once per +project it is invoked on: + +```just +[group('Agent Skills')] +skills-validate *args: + dotnet msbuild "src/src/Acme/Acme.csproj" -t:ValidatePurviewSkills -v:n {{ args }} +``` + +## Why the recipes name the solution + +`skills_project` defaults to `solution_file`. Every skill target runs once for the whole solution, +however many projects it holds: each project hands the work to the same elected leader, and MSBuild +runs it once. A single project works too. An old `Justfile.skills.just` pointed at a hand-picked +"small" project gets the same treatment: delete it, and let `skills.just` name the solution. + +## Details that break a recipe if missed + +- **Quote a multi-target `-t:`.** `"-t:UpdatePurviewSkills;FixPurviewSkills"` — unquoted, `sh` ends the + command at the `;` and runs `FixPurviewSkills` as a separate, failing command. The same holds under + PowerShell. +- **Keep `*args` last** so a caller can pass properties: `just skills-update -p:PurviewSkillsDryRun=true`. +- `FixPurviewSkills` after `UpdatePurviewSkills` does nothing unless `PurviewSkillsAgents` is set, so + it is safe in the recipe either way. +- `skills-install` with no lockfile fails with `PRSKL0007`: add a source first with `skills-add`. + +## Runner and agents belong in MSBuild, not in the recipe + +Set repository-wide choices once in `Directory.Build.props`, not as flags repeated in each recipe, so +a plain `dotnet msbuild -t:...` behaves the same as the recipe: + +```xml + + + bun + + universal + +``` + +One-off overrides go on the command line. `dotnet msbuild -p:` splits values on `,` and `;`, so escape +the separator: `just skills-update -p:PurviewSkillsAgents=claude-code%2Ccodex`. + +| Property | Effect | +| -- | -- | +| `PurviewSkillsDryRun=true` | Print every CLI command; run none | +| `PurviewSkillsRunner=npx` | Launch the CLI through another runner (`bunx`, `pnpm`, `yarn`, `deno`) | +| `PurviewSkillsSource=owner/repo#ref` | What `skills-add` sets: the source to install from | + +## Replacing hand-written skill scripts + +| Hand-written | Replace with | +| -- | -- | +| `scripts/update-agent-skills.ts`, `"skills:update"` | `skills-update` (`UpdatePurviewSkills`) | +| `--update` / `--no-update` | Nothing: the re-add refreshes installed skills and adds new ones, so there is no separate `skills update` step to switch | +| `--dry-run` | `-p:PurviewSkillsDryRun=true` | +| `scripts/ensure-universal-skills.ts check`, `"skills:check"` | `skills-check` with `PurviewSkillsAgents=universal` | +| `scripts/ensure-universal-skills.ts fix`, `"skills:fix"` | `skills-fix` | +| a hard-coded `-a universal` | `PurviewSkillsAgents` — or leave it empty to auto-detect | + +Update is `skills add`, not `skills update`, so it also installs skills a source has **gained**: each +source is re-added with `--skill "*"`. Mention that when migrating. + +Before deleting the scripts, run `just skills-update -p:PurviewSkillsDryRun=true` and compare the +printed commands with what the old script ran. Then delete the scripts and their `package.json` +entries — but keep `package.json` itself, which Purview.BuildSdk reads the version from. + +An existing `skills-recommend`, `skills-verify` or `skills-list` recipe has no target behind it any +more: delete it, and its callers. diff --git a/.agents/skills/purview-buildsdk-msbuild-properties/SKILL.md b/.agents/skills/purview-buildsdk-msbuild-properties/SKILL.md new file mode 100644 index 00000000..00111249 --- /dev/null +++ b/.agents/skills/purview-buildsdk-msbuild-properties/SKILL.md @@ -0,0 +1,233 @@ +--- +name: purview-buildsdk-msbuild-properties +description: Add, rename, deprecate or remove an MSBuild property in a package built with Purview.BuildSdk, and describe it in an MSBuild Editor .buildschema.json sidecar so it gets csproj IntelliSense. Use when a package ships .props/.targets under Sdk/, build/ or buildTransitive/ and needs a new setting, a compiler-visible (build_property.*) value for its analyzers, a documented default, or a check that its schema and props agree (ValidatePurviewPropertySchema, PRPSC0001-PRPSC0004). Also covers where a sidecar must sit to be loaded, when a property must be set before the Sdk.props import, and declaring a new sidecar in .config/purview-build.json PackValidation.RequiredContent - including a Purview.Build "contains undeclared content ... not defined in RequiredContent" failure. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# MSBuild properties and their schema + +A property a package ships is described in several places: its definition and default, the +`CompilerVisibleProperty` list when an analyzer reads it, the package's docs, and an MSBuild Editor +sidecar schema that gives it csproj IntelliSense. They drift unless one of them is canonical and the +others are checked against it. + +**The sidecar is canonical.** Write the description, type and default there first, then make the code +and the docs agree with it. `ValidatePurviewPropertySchema` checks the part that can be checked +mechanically. + +## Where a sidecar goes + +MSBuild Editor reads `.buildschema.json` — the **full** file name plus the +suffix, in the **same folder** as the file it describes. Verified in the editor's source +(`MSBuildSchemaProvider.GetSchema`: `path + ".buildschema.json"`). + +| The package ships | The consumer's build imports | The sidecar is | +| -- | -- | -- | +| an MSBuild SDK (`Sdk/Sdk.props`, `Sdk/Sdk.targets`) | `Sdk/Sdk.props` | `Sdk/Sdk.props.buildschema.json` | +| `build/Acme.Kit.props` | `build/Acme.Kit.props` | `build/Acme.Kit.props.buildschema.json` | +| `buildTransitive/Acme.Kit.props` | `buildTransitive/Acme.Kit.props` | `buildTransitive/Acme.Kit.props.buildschema.json` | + +Three consequences: + +- `Sdk.buildschema.json` or `Acme.Kit.buildschema.json` is **never loaded**: the extension of the + described file is part of the name. `PRPSC0004` reports a sidecar with no file beside it. +- One sidecar is enough per package. The editor loads every sidecar for every file it imports and + applies them project-wide, so describe all the package's properties beside the file a consumer + always imports — usually the `.props` — even those its `.targets` defines. +- A package that ships the same file under both `build/` and `buildTransitive/` needs the sidecar in + both, because a direct and a transitive reference import different copies. + +With Purview.BuildSdk, author the files under the project's `Sdk/` folder: `PurviewAutoSdkPack` maps +`Sdk/build/**` to `build/**` and `Sdk/buildTransitive/**` to `buildTransitive/**`, and a sidecar +travels with its neighbour because it sits in the same folder. An MSBuild SDK project disables +`PurviewAutoSdkPack` and packs `Sdk/**` to `Sdk/` itself; a sidecar there is picked up by that same +item. + +### Declare the sidecar to pack validation + +A sidecar is a new file in the package, and a repository built with Purview.Build validates every +package's contents against `.config/purview-build.json`. Whenever you add a sidecar — or any other +`.props`/`.targets` the package ships — open that file and check `PackValidation.RequiredContent`. +It is keyed by lower-case package id, and lists the package-relative path of every file the package +must contain: + +```json +"PackValidation": { + "RequiredContent": { + "acme.kit": [ + "build/Acme.Kit.props", + "build/Acme.Kit.props.buildschema.json", + "buildTransitive/Acme.Kit.props", + "buildTransitive/Acme.Kit.props.buildschema.json" + ] + } +} +``` + +Add each sidecar beside the file it describes, in the list's existing order. Both directions fail +the pipeline's `ValidatePackModule`, and neither shows up until `just pipeline-pack-validate` or a +release: + +- **A packed file the list does not name** — `contains undeclared content 'Sdk/Sdk.props.buildschema.json' + that is not defined in RequiredContent`. The package is right; the list is stale. +- **A listed file the package lacks** — the sidecar was renamed, or did not pack. Fix the pack, not + the list. + +With no `.config/purview-build.json`, or no entry for the package, there is nothing to update. To +check, pack and validate without publishing: + +```bash +just pipeline-pack-validate # or: dotnet purview-build --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None +``` + +## Add a property + +1. **Define it, with its default, in the `.props`.** Guard it so a value set earlier wins: + + ```xml + false + ``` + + Give a boolean an explicit `true`/`false` default. Leave a property unset only when "unset" is a + real third state — "detected", "inherited from another property" — and say so in the schema. + +2. **Decide whether it must be set before the import.** A `.props` file is evaluated before the + project body. If anything in the `.props` (or a file it imports) *reads* the property — in a value + or a `Condition`, other than its own `'$(X)' == ''` guard — a value set in the project file arrives + too late. Then it must be set in `Directory.Build.props` **before** the ``, and its + description says so. If only the `.targets` reads it, a project file can set it too; prefer that + shape for anything per-project. + +3. **Make it compiler-visible only if an analyzer or generator reads it.** Add it beside the package's + other exports, with a ``: + + ```xml + + + When true, the Acme analyzers report ACME0001 as an error. + + + ``` + + The analyzer reads `build_property.AcmeKitStrict` from + `AnalyzerConfigOptionsProvider.GlobalOptions`. Nothing else exposes an MSBuild property to the + compiler, and an export nothing reads is noise in every consumer's generated `.editorconfig`. + +4. **Describe it in the sidecar.** See [references/schema-format.md](references/schema-format.md) for + the entry format, types and enumerations: + + ```json + "AcmeKitStrict": { + "description": "Reports ACME0001 as an error instead of a warning.", + "type": "bool", + "defaultValue": "false" + } + ``` + + - `defaultValue` is the value a plain project evaluates to — not what it is "usually". + - No default? End the description with `No default: .` — "detected from the project", + "unset, which is treated as false". + - Must be set before the import? Say `Set it before the \`Sdk.props\` import.` (or name the + package's own `.props`). + +5. **Document it** in the package's README or docs table, with the same default. + +6. **Record it** in `CHANGELOG.md` under the heading for the version in `package.json` → + `### Added`. Not `[Unreleased]`: a merge to `main` publishes that version, so the entry would read as + unreleased until it was already live. `[Unreleased]` is only for a pre-release version. + +7. **Check it.** Run `dotnet msbuild -t:ValidatePurviewPropertySchema` (see below), then the + package's own tests. If this property brought the package's **first** sidecar, or a new + `.props`/`.targets` file, declare it to pack validation as well + ([above](#declare-the-sidecar-to-pack-validation)). + +## Rename a property + +Never rename a released property in place: every consumer that sets the old name silently loses it. + +1. Add the new name as in *Add a property*, defaulting from the old one so an existing setting still + works: + + ```xml + $(AcmeKitStrict) + false + ``` + +2. Read only the new name everywhere else, including the `CompilerVisibleProperty` export. Export the + old one too until it is removed if analyzers in the wild read it. +3. Keep the old entry in the sidecar, with a `deprecationMessage` naming the replacement, so the editor + flags the old name where it is set: + + ```json + "AcmeKitStrict": { + "description": "Replaced by AcmeKitStrictMode.", + "type": "bool", + "deprecationMessage": "Use AcmeKitStrictMode instead." + } + ``` + +4. Changelog: `### Added` for the new name, `### Deprecated` for the old. + +## Deprecate a property + +Keep its behaviour and its default, add a `deprecationMessage` to its sidecar entry saying what to do +instead, mark the docs row deprecated, and add a `### Deprecated` changelog entry. A deprecated +property is still a property: it keeps its schema entry until it is removed. + +## Remove a property + +Only in a major version, and in one change: the definition, every read, the `CompilerVisibleProperty` +item, the sidecar entry and the docs row. Leaving any one behind is exactly what the checks catch — +`PRPSC0002` for a sidecar entry nothing defines. Changelog: `### Removed`, naming what to set instead. + +## Checking a package: `ValidatePurviewPropertySchema` + +Purview.BuildSdk ships an opt-in target for any package that authors `.props`/`.targets`: + +```xml + + + true + + RootNamespace;Version + +``` + +With it on, the check runs before `Pack`. Run it by name at any time: + +```bash +dotnet msbuild src/Acme.Kit/Acme.Kit.csproj -t:ValidatePurviewPropertySchema +``` + +It reads every `.props`/`.targets` under `PurviewPropertySchemaDirectory` (default: the project's +`Sdk/` folder) and every sidecar beside them, and fails with: + +| Id | Meaning | Fix | +| -- | -- | -- | +| `PRPSC0001` | a `CompilerVisibleProperty` no sidecar describes | add the entry, or list the name in `PurviewPropertySchemaIgnoredProperties` | +| `PRPSC0002` | a sidecar entry no file defines, reads or exports | remove the entry, or define the property | +| `PRPSC0003` | a sidecar that is not JSON, or has no `"properties"` object | fix the file | +| `PRPSC0004` | a sidecar with no `.props`/`.targets` beside it | rename it to `.buildschema.json` | + +It is off by default and never runs in an ordinary build, so turning it on changes nothing for the +package's consumers. It checks agreement, not prose: whether a description is *right* is the review's +job. + +## Getting the IntelliSense + +Install the **MSBuild Editor** extension in Visual Studio +(). It loads the sidecar of every file a project imports, SDKs resolved +through `global.json` `msbuild-sdks` included, and offers completion, the description, the default +and — for an enumeration — the allowed values. Without the extension a sidecar is inert. + +## Guardrails + +- **Do not change a released default** as part of documenting it. A wrong document is fixed in the + document; a default change is a behaviour change with its own changelog entry. +- **Do not export a property "in case".** Each `CompilerVisibleProperty` lands in every consumer's + generated analyzer config. +- **Do not hand-edit generated sidecars.** If a package generates its sidecar, change the generator. +- **Keep the meta-schema draft as it is** (`draft-04`): Visual Studio rejects newer drafts. diff --git a/.agents/skills/purview-buildsdk-msbuild-properties/references/schema-format.md b/.agents/skills/purview-buildsdk-msbuild-properties/references/schema-format.md new file mode 100644 index 00000000..1f4e7b30 --- /dev/null +++ b/.agents/skills/purview-buildsdk-msbuild-properties/references/schema-format.md @@ -0,0 +1,87 @@ +# The `.buildschema.json` format, for properties + +The meta-schema is +. +It declares **JSON Schema draft-04** on purpose — Visual Studio fails to validate against anything +newer — and sets `additionalProperties: false` almost everywhere, so an unknown key is an error, not +an extension point. There is no slot for custom metadata: anything a property needs to say goes in +its `description`. + +## The file + +```json +{ + "$schema": "https://github.com/mhutch/MonoDevelop.MSBuildEditor/raw/main/MonoDevelop.MSBuild/Schemas/buildschema.json", + "license": "MIT", + "properties": { + "AcmeKitStrict": { + "description": "Reports ACME0001 as an error instead of a warning.", + "type": "bool", + "defaultValue": "false" + } + }, + "types": {} +} +``` + +Top-level keys: `$schema`, `license`, `properties`, `items`, `targets`, `metadata`, `types`, +`intellisenseImports`. All optional. + +## A property entry + +A string is shorthand for `{ "description": "..." }`. The object form allows only: + +| Key | Use | +| -- | -- | +| `description` | What it does, what its values mean, whether it must be set before the import, and — when there is no `defaultValue` — `No default: .` | +| `type` | An intrinsic type name, an inline type, or `{ "$ref": "#/types/" }`. | +| `defaultValue` | The evaluated default, as a string. Omit it rather than guess. | +| `isList`, `listSeparators` | A list value. `listSeparators` is `";"`, `","` or `";,"`. | +| `isLiteral` | Only literal values, no `$(...)` expressions. | +| `deprecationMessage` | Marks the property deprecated; shown where it is set. Must not be empty. | +| `versionInfo`, `helpUrl` | Optional. | + +## Intrinsic types + +`bool`, `int`, `string`, `guid`, `url`, `version`, `version-suffixed`, `nuget-id`, `nuget-version`, +`file`, `folder`, `folder-with-slash`, `file-or-folder`, `extension`, `target-framework`, +`target-framework-moniker`, `runtime-id`, `configuration`, `platform`, `warning-code`, +`clr-namespace`, `clr-type`, `clr-type-name`, `sdk`, `sdk-version`, `target-name`, `item-name`, +`property-name`, `culture`, `lcid` and a few more — the meta-schema's `valueTypeIntrinsic` is the +complete list. Pick the narrowest: `warning-code` with `isList` for a `NoWarn`-style list, +`target-framework` with `isList` for a TFM set, `folder` for a directory. + +## Enumerations + +Define the values once under `types` and reference them: + +```json +"properties": { + "AcmeKitMode": { + "description": "How strictly the kit applies its rules.", + "type": { "$ref": "#/types/acme-kit-mode" }, + "defaultValue": "Warn" + } +}, +"types": { + "acme-kit-mode": { + "description": "A strictness level.", + "values": { + "Off": "No checks.", + "Warn": "Report as warnings.", + "Error": "Report as errors." + } + } +} +``` + +A type name is lowercase and hyphenated (`^[a-z][a-z\d-]*$`). Set `"allowUnknownValues": true` when +the listed values are suggestions rather than the whole set — a package runner, a provider package. +A value can be an object with `description`, `deprecationMessage`, `aliases` and `helpUrl` instead of +a plain description. + +## Validating + +The meta-schema can be associated in VS Code (`json.schemas` → `fileMatch: ["/*.buildschema.json"]`) +or in Visual Studio's JSON editor *Schema* box. Purview.BuildSdk validates its own sidecar against a +vendored copy of the meta-schema in its test suite, so the check runs offline. diff --git a/.agents/skills/purview-buildsdk-project-detection/SKILL.md b/.agents/skills/purview-buildsdk-project-detection/SKILL.md index bd80b3f5..a893896b 100644 --- a/.agents/skills/purview-buildsdk-project-detection/SKILL.md +++ b/.agents/skills/purview-buildsdk-project-detection/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-buildsdk-project-detection -description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, ServerShared, client-side, CLI, web, worker, Aspire host, container, Roslyn component, Blazor WebAssembly client, Razor class library, Blazor host or BFF, 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. +description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, ServerShared, client-side, CLI, web, worker, Aspire host, container, Roslyn component, Blazor WebAssembly client, Razor class library, Blazor host or BFF, or why it picked the target framework(s) it did, 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 @@ -93,13 +93,28 @@ dotnet msbuild -t:ResolvePurviewBlazorHostProject -getProperty:IsBlazorHo What each Blazor and BFF classification applies - and the browser-safe library check - is in [references/blazor.md](references/blazor.md). +### Target frameworks + +A project that declares neither `TargetFramework` nor `TargetFrameworks` gets the SDK's default set, +`net10.0;net11.0` (`Current`). A `PurviewTargetFrameworkSet` (`Current`, `Latest`, `Supported`, `Broad`, +`All`) resolves the same way; an unknown name fails with `PurviewInvalidTargetFrameworkSet`. A Roslyn +component keeps `netstandard2.0` and ignores the sets (`PurviewTargetFrameworkSetIgnored`). + +An explicit `TargetFramework` or `TargetFrameworks` always wins, wherever it is set: one the SDK cannot +see in the project XML (a `Condition`, an imported file, a `Directory.Build.props` value) is reconciled +after the project body, before the .NET SDK decides whether to multi-target. `PurviewTargetFrameworkSet` +is still read from the **project XML**, so a `Condition` on it is not seen and is reported as +`PurviewConditionedProjectDeclaration`. See `purview-buildsdk-configuration` for how to set them. + ## Namespace and identity 1. `PurviewLogicalProjectName` is `NamespacePrefix` plus the project name, de-duplicated when the project name already starts with the prefix's tail. 2. `RootNamespace` defaults to `PurviewLogicalProjectName`. -3. Known suffixes are stripped from `RootNamespace` — shared and shared-testing names, and segments - such as `Core`, `EF`, `Shared`, `ClientShared`, `ServiceDefaults`. +3. Known suffixes are stripped from `RootNamespace` in two passes: while `Sdk.props` evaluates, the + shared, server-shared and shared-testing names plus `Core`, `EF`, `Shared`, `ClientShared` and + `ServiceDefaults`; then, during the build, the wider `NamespaceRemoveSuffix` list (`Infrastructure`, + `Abstractions`, `Contracts`, …). A `RootNamespace` declared in the project file is left as written. 4. Test suffixes are stripped too, so `Acme.Api.UnitTests` maps back to `Acme.Api`. 5. `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace` — except where stripping removed a segment, where they use the full `PurviewLogicalProjectName` so the artifact @@ -110,7 +125,8 @@ What each Blazor and BFF classification applies - and the browser-safe library c ## Automatic packages and references -- **Non-test C# projects**: SourceLink (unless `DisableSourceLink`), the Purview telemetry packages +- **Non-test C# projects**: a SourceLink provider package (`SourceLinkPackageName`, default + `Microsoft.SourceLink.GitHub`; `DisableSourceLink=true` opts out), 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 @@ -127,7 +143,10 @@ Project-reference probing is layout-driven and covered by `purview-buildsdk-proj ## 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. +surface in command-line builds and in Visual Studio. The code-fix assembly ships beside it. The rules +read the SDK's compiler-visible properties (`build_property.*`) rather than re-deriving project state — +`PDS0008`/`PDS0009`, for example, are gated on `build_property.IsBffProject`. The exported set is in +`purview-buildsdk-configuration`. | Rule | What it enforces | | -- | -- | @@ -137,10 +156,10 @@ surface in command-line builds and in Visual Studio. The code-fix assembly ships | `PDS0008` | A BFF's authentication cookie is not set `HttpOnly = false` | | `PDS0009` | A BFF's authentication cookie is not set `SameSite = None` | -`PDS0004` follows .NET naming guidance: well-known framework spellings (`Sql`, `Guid`, `Uuid`, -`Url`, `Dns`, `Tcp`, `Http`, `Xml`, `Db`, …) are exempt — `Db` among them, so `DbContext`, -`DbConnection` and `DbSet` are never flagged. A small set is still uppercased: `Api`, `Ai`, `Ui`, -`Io`, `Os`, `Cpu`, `Gpu`, `Cli`, `Gui`, `Ram`, `Ssh`. Members mandated by a contract — interface +`PDS0004` follows .NET naming guidance: well-known framework spellings (`Sql`, `Uuid`, `Url`, `Dns`, +`Tcp`, `Http`, `Xml`, `Db`, …) are exempt — `Db` among them, so `DbContext`, `DbConnection` and `DbSet` +are never flagged. A small set is still uppercased: `Api`, `Ai`, `Ui`, `Io`, `Os`, `Cpu`, `Gpu`, `Cli`, +`Gui`, `Ram`, `Ssh`, `Dsl`. Members mandated by a contract — interface implementations and base-class overrides — are never renamed. Customise through `dotnet_analyzer_configuration.pds0004.allowed_words` (exempt segments), @@ -155,9 +174,11 @@ diagnostics under `Extensions/`, so no `#pragma` suppressions are needed there. Two Visual Studio refactorings ship with them, both described in [references/detection.md](references/detection.md). -## When changing a detection rule +## When a classification is wrong -1. Keep the `.csproj` filename equal to its directory name. -2. Update `README.md` and the shipped skills in the same change — a rule true in code and false in - prose is this repository's recurring failure. -3. Add or update the integration test in `src/tests/BuildSdk.IntegrationTests/` that pins it. +1. Confirm the computed value with `dotnet msbuild -getProperty:` before changing + anything. +2. Prefer fixing the name or placement so the convention applies; keep the `.csproj` filename equal + to its directory name. +3. Otherwise set the flag explicitly before the SDK import (`IsBffProject` and `IsClientSideProject` + can also be set in the project), and say why in a comment. diff --git a/.agents/skills/purview-buildsdk-project-detection/references/blazor.md b/.agents/skills/purview-buildsdk-project-detection/references/blazor.md index c65d1532..8c2e6f92 100644 --- a/.agents/skills/purview-buildsdk-project-detection/references/blazor.md +++ b/.agents/skills/purview-buildsdk-project-detection/references/blazor.md @@ -12,10 +12,31 @@ prevents. Nothing here changes a project that is not a WebAssembly client, a Bla | `IsContainerProject=false`, so no `PublishAot`, `InvariantGlobalization` or Docker defaults | `PublishAot` on a client is a publish that fails, and the Blazor SDK owns globalisation | `DisablePurviewBlazorWebAssemblyDefaults=true` before the SDK import | | no automatic `../Shared*/Shared*.csproj` reference | a server-side library joining the browser download because of a folder name | `DisablePurviewBlazorWebAssemblyDefaults=true` before the SDK import | | the browser-safe reference check (`PRBLZ0001`) | a library joining the download without anyone deciding it was safe there | `DisablePurviewBrowserSafeReferenceCheck=true` | +| the managed `Directory.Build.rsp` written as `-mt:false`, naming the client | the WebAssembly SDK's `ReadWasmProps` failing under `-mt` with `MSB4060`, an error that points nowhere near the response file | `PurviewMultiThreadedBuild=true` (which then fails with `PRBLZ0002`) | +| `PRBLZ0002` when a `net11.0`+ client is built under `-mt` anyway | the same failure, reported before `PrepareForBuild` and naming where `-mt` came from | none: the build would fail anyway | Globalisation, trimming mode and the Blazor runtime settings are deliberately left alone: the Blazor SDK owns them, and a second default would compete with it. +### The multithreaded build (`PRBLZ0002`) + +A `net11.0` client cannot build under MSBuild's `-mt`, because the WebAssembly SDK's `ReadWasmProps` +task comes from a custom task factory that cannot run in the out-of-process task host. The +`Directory.Build.rsp` the SDK manages is per repository, so with `PurviewMultiThreadedBuild` unset: + +- a client writes `-mt:false` and a `# -mt:false: the Blazor WebAssembly client '' ...` line; +- every other project leaves that file alone while `` exists, and writes `-mt` again once it + does not; +- a `PurviewMultiThreadedBuild` the user set always wins, and a file without the marker line is never + rewritten. + +A client built under `-mt` anyway (the command line, a response file the user owns, +`PurviewMultiThreadedBuild=true`, or the first build after upgrading from 2.0.x) stops with `PRBLZ0002`. +The message says whether `-mt` came from the command line or which `Directory.Build.rsp`, and gives the +fix: `false`, or `-mt:false`. After an upgrade, +the managed file has already been rewritten, so building again is the fix. A `net10.0` client builds +under `-mt` and is not checked. + ### The automatic packages The SDK adds `Purview.Telemetry.SourceGenerator` and `Microsoft.Extensions.Telemetry.Abstractions` @@ -70,12 +91,14 @@ Applies to class libraries only, and only what the project has not set: - ``, so `CA1416` reports an API the browser does not support; - `WarningsAsErrors` for every trim (`IL2001`-`IL2128`), single-file (`IL3000`-`IL3005`) and AOT (`IL3050`-`IL3059`) diagnostic and `CA1416`, listed in `PurviewBrowserSafeWarningCodes`. - `TreatWarningsAsErrors` is not touched. + `TreatWarningsAsErrors` is not touched (it is `true` for every project by default), so these stay + errors in a project that turns it off. `CA1416` is `suggestion` in the shipped `.editorconfig`. The compiler's `/warnaserror+:CA1416` outranks an `.editorconfig` severity, so the error still fires without the repository's setting changing. The consequence of `PurviewBrowserSafeWarningsAsErrors=false` is that the trim and AOT -diagnostics stay warnings while `CA1416` falls back to the repository's configured severity. +diagnostics follow `TreatWarningsAsErrors` (`true` by default) while `CA1416` falls back to the +repository's configured severity. What it cannot catch: file and network I/O. `File` works against the browser's in-memory file system and `HttpClient` works through `fetch`, so neither is annotated as unsupported, and no analyzer reports diff --git a/.agents/skills/purview-buildsdk-project-detection/references/detection.md b/.agents/skills/purview-buildsdk-project-detection/references/detection.md index 51243c96..155e345b 100644 --- a/.agents/skills/purview-buildsdk-project-detection/references/detection.md +++ b/.agents/skills/purview-buildsdk-project-detection/references/detection.md @@ -29,18 +29,19 @@ These are matched whole, not as substrings. `SharedModels` is not a shared proje A shared-testing project is forced to `PurviewSharedTestingOutputType` (default `Library`), with `IsTestProject` and `IsTestingPlatformApplication` cleared, because the test packages otherwise flip -it into an executable test host. Set `PurviewSharedTestingOutputType=Exe` before the SDK import to +it into an executable test host. Set `PurviewSharedTestingOutputType=Exe` to keep that package-driven shape. ## Packable projects Defaults applied only where the project supplied nothing: `GenerateDocumentationFile`, -`IncludeSymbols`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl`, `EmbedUntrackedSources`, -`IncludeSource`, `DebugType=portable`. +`IncludeSymbols`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl`, `EmbedUntrackedSources` and +`DebugType=portable`. `IncludeSource` stays `false`. - Portable PDBs ship in the `.snupkg`. The `.nupkg` receives none unless the project opts in. -- The repository-root `README.md` is packed and registered as `PackageReadmeFile` when it exists and - nothing else is already being packed as one. +- A README is packed and registered as `PackageReadmeFile` unless one is configured: the project's own + `README.md` wins, else the repository-root one; a README-named file already being packed (an + `Sdk/README.md`) is used instead. - Non-packable projects, web apps included, default `WarnOnPackingNonPackableProject=false`, so a solution-wide `dotnet pack` skips them silently. - `ContinuousIntegrationBuild` is set only by real CI environment variables. Packability alone never @@ -76,8 +77,8 @@ dual-role component that wants normal library symbol packaging. | -- | -- | | CLI | `OutputType=Exe`, `appsettings*.json` included as content | | Container | `InvariantGlobalization`, `PublishAot`, Linux Docker defaults, container tooling packages | -| Web SDK | `Microsoft.AspNetCore.OpenApi.Generated` added to `InterceptorsNamespaces`, unless marked as a separate web-project mode | -| Aspire host | `OutputType=Exe` | +| Web SDK | `Microsoft.AspNetCore.OpenApi.Generated` added to `InterceptorsNamespaces`, unless `IsWebProject=true` | +| Aspire host | `OutputType=Exe` (unless a test or shared-testing project), `CA1515` suppressed | | Blazor WebAssembly client | not packable, no container defaults, no automatic `Shared`/`ServerShared` reference, the browser-safe reference check - see [blazor.md](blazor.md) | | ServerShared project | referenced automatically by every non-client-side project; never by a WebAssembly client, browser-safe library, Razor class library, client-side name, or `Shared`/`SharedTesting` project | | Blazor host | an automatic reference to its `.Client` - see [blazor.md](blazor.md) | @@ -92,7 +93,7 @@ Two ship with the code-fix assembly, both serving the `PDS0002` convention: targeting several receiver types. It produces one `Extensions` class per receiver (a generic `this TBuilder where TBuilder : IHostApplicationBuilder` receiver becomes `HostApplicationBuilderExtensions`) and places each under `Extensions//`. -- **Move extensions class to conventional location** — offered on a misplaced single-receiver - extensions class. It re-paths the file to `Extensions//`, fixes the namespace +- **Move extensions class to conventional location** (or **Rename extensions class to '…'** when it is + already in place but not conventionally named) — offered on a single-receiver extensions class. It re-paths the file to `Extensions//`, fixes the namespace to the receiver's, and updates `using` directives in other referencing documents so the move compiles. diff --git a/.agents/skills/purview-buildsdk-project-layout/SKILL.md b/.agents/skills/purview-buildsdk-project-layout/SKILL.md index 6c19c3d4..e339bd1a 100644 --- a/.agents/skills/purview-buildsdk-project-layout/SKILL.md +++ b/.agents/skills/purview-buildsdk-project-layout/SKILL.md @@ -1,6 +1,6 @@ --- 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, laying out a Blazor Web App host, WebAssembly client and browser-safe Contracts library, 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. +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, choosing the target framework(s) for a project, aligning namespaces and assembly identity with NamespacePrefix, laying out a Blazor Web App host, WebAssembly client and browser-safe Contracts library, 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 @@ -40,7 +40,7 @@ introduce new structure when no established pattern exists. - The `.csproj` filename must equal its containing directory name, unless `DisableProjectFileNamingConventionCheck=true`. -- Source projects under `src/`, test projects under `tests/`. +- Source projects under `src/src/`, test projects under `src/tests/`, the solution in `src/`. - Place a new project beside similar ones — same language, layer and test type. - One test type per project by default. @@ -91,6 +91,15 @@ Shared testing: `SharedTestingFramework`, `SharedTestingInfrastructure`, `Shared `SharedTestingUtilities`, `SharedTestingUtils`, `SharedTestingLibrary`, `SharedTestingLib`, `SharedTestingHelpers`. +## Target frameworks + +A new project should inherit the SDK's default set (`net10.0;net11.0`) rather than pin its own TFMs, so +a lifecycle change is one SDK bump rather than an edit in every project. When a project genuinely needs +a different set, select one with `PurviewTargetFrameworkSet` (`Current`, `Latest`, `Supported`, `Broad`, +`All`) or declare `TargetFramework`/`TargetFrameworks` — an explicit value always wins over every set, +with or without a `Condition`. A Roslyn component targets `netstandard2.0`. +See `purview-buildsdk-configuration`. + ## Automatic project references to preserve Layout is what makes these work. Move a project off the convention and you own the reference. diff --git a/.agents/skills/purview-buildsdk-repository-review/SKILL.md b/.agents/skills/purview-buildsdk-repository-review/SKILL.md index 2be4c08f..15f90c00 100644 --- a/.agents/skills/purview-buildsdk-repository-review/SKILL.md +++ b/.agents/skills/purview-buildsdk-repository-review/SKILL.md @@ -36,13 +36,14 @@ 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 +- `global.json` (the pinned SDK version) and repo `package.json` (the product version) - 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/`. +- Source projects under `src/src/`, test projects under `src/tests/`, the solution in `src/`. - `.csproj` filename equals its containing directory name — unless `DisableProjectFileNamingConventionCheck=true`, which is itself a finding worth surfacing. - Short project names, with `NamespacePrefix` carrying the repository identity. @@ -53,6 +54,12 @@ Inspect, in this order, before forming any opinion: 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. +- Target frameworks: a project should inherit the SDK's default set (`net10.0;net11.0`) or select a + curated `PurviewTargetFrameworkSet` (`Current`, `Latest`, `Supported`, `Broad`, `All`), not hard-code + a TFM list. A hard-coded list is an incremental opportunity. A TFM set in `Directory.Build.targets` + is structural debt: it is evaluated after the .NET SDK decides whether to multi-target, so it does + not take effect. A `Condition`-guarded `PurviewTargetFrameworkSet` is not seen at all + (`PurviewConditionedProjectDeclaration`). ## 3. Verify with MSBuild, do not infer from folder names @@ -91,18 +98,19 @@ go stale: ## 6. Check how agent skills are managed -Only relevant when the repository has a `skills-lock.json`, an agent skills directory, or a package -reference that ships skills: +Only relevant when the repository has a `skills-lock.json` or an agent skills directory: - **Anything that runs `InstallPurviewSkills`, `UpdatePurviewSkills` or `FixPurviewSkills` implicitly** — an `AfterTargets`/`BeforeTargets` hook, a `*DependsOn` property, a git hook, a - `just` recipe dependency — is structural debt: the SDK fails it with `PRSKL0009`, and skill changes - should only happen when somebody asks. + `just` recipe dependency — is structural debt: skill changes should only happen when somebody asks. + The SDK fails the build-time cases with `PRSKL0009` (inside a build, a restore or a design-time + build); a git hook or a recipe dependency that runs the target on its own is **not** caught, so + look for those by reading the hook configuration and the `Justfile`. - **Hand-written skill scripts** (`update-agent-skills.ts`, `ensure-universal-skills.ts`, - `package.json` `skills:*` entries) are an incremental opportunity: the SDK targets replace them, and - pin package skills to their package's commit where the scripts install latest. See - `purview-buildsdk-skills-justfile`. -- **Drift**: run `dotnet msbuild -t:RecommendPurviewSkills` and quote any `PRSKL0010`/`PRSKL0011`. + `package.json` `skills:*` entries) are an incremental opportunity: the SDK targets wrap the same + standard CLI, from the repository root, once per solution. See `purview-buildsdk-justfile`. +- **A fresh clone**: a committed `skills-lock.json` with no agent skills directory beside it means the + skills were never restored — `-t:InstallPurviewSkills` (`skills experimental_install`) restores them. - **Layout**: if `PurviewSkillsAgents` is set, run `-t:CheckPurviewSkills` and quote any `PRSKL0015`. If it is not set, every agent directory is legitimate — that is acceptable variance, not a finding. diff --git a/.agents/skills/purview-buildsdk-settings-docs-sync/SKILL.md b/.agents/skills/purview-buildsdk-settings-docs-sync/SKILL.md new file mode 100644 index 00000000..728a0a61 --- /dev/null +++ b/.agents/skills/purview-buildsdk-settings-docs-sync/SKILL.md @@ -0,0 +1,229 @@ +--- +name: purview-buildsdk-settings-docs-sync +description: Change or document a Purview.BuildSdk setting without leaving part of the surface behind. Use when flipping a .NET 11 compiler feature default (Runtime Async, memory-safety rules), adding or renaming a property, or making sure every skill and doc explains how to set required settings and versions, the target framework(s), and the compiler-visible (build_property.*) settings. Repository-local guidance for purview-dev/build-sdk. +license: MIT +metadata: + scope: repository-local + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Keeping settings, docs and skills in step + +> **Repository-local.** This skill lives in `.agents/skills/` and is tracked by name (the rest of that +> folder is gitignored); it is not packed and not published. It is deliberately **not** linked from +> `AGENTS.md`: `RepositorySkillsSelfTests` fails any AGENTS.md link that points into `.agents/skills/`. +> The published skills are authored under `skills/` and are a different thing entirely. + +## The general sweep is a published skill + +This skill is the **settings** sweep for this repository. Any other documentation change — a renamed +recipe, a new wiki page, a moved page, a skill whose scope grew, an audit of whether the docs are +still true — follows the published +[`purview-buildsdk-docs-sync`](../../../skills/purview-buildsdk-docs-sync/SKILL.md) skill: every +claim checked against the code, and the wiki kept hierarchical. In this repository the wiki's root +pages are exactly `Getting-Started.md` (the site's `docs.rootPage`), `Configuration-Reference.md` and +`Repository-Bootstrap.md` (the site catalogue's `docsPage` deep links); every other page lives in one +topic folder, and `DocumentationWikiTests` holds the folders, `_Sidebar.md` and `Home.md` to that. + +## Start from the schema + +`src/src/BuildSdk/Sdk/Sdk.props.buildschema.json` is the canonical description of every SDK property — +description, type and default — and `PropertySchemaTests` holds the code, the `CompilerVisibleProperty` +list, `README.md` and `docs/wiki/Configuration-Reference.md` to it. The generic method (where a +sidecar goes, when a property is pre-import, when to export it, how to rename or retire one) is the +published [`purview-buildsdk-msbuild-properties`](../../../skills/purview-buildsdk-msbuild-properties/SKILL.md) +skill. In this repository, adding a property means: + +1. `Sdk/Props/Defaults.props` — the default, guarded with `'$(X)' == ''`. +2. `Sdk/Sdk.props.buildschema.json` — the entry. `defaultValue` is what a plain class library evaluates + to; without one, end the description with `No default: .`; when `Sdk.props` (or a file it + imports) reads the property, say `Set it before the \`Sdk.props\` import.` The test computes both + from the XML and fails if the description disagrees. +3. `Sdk/Sdk.props` — a `CompilerVisibleProperty` with a ``, only if an analyzer reads it. +4. `README.md` and `docs/wiki/Configuration-Reference.md` — a row in the table for its area (the + Default cell opens with the backticked default, or italics when there is none), plus a row in each + *Compiler-visible SDK properties* table when exported. +5. `CHANGELOG.md` — under the `package.json` version's heading (see the Changelog row below). +6. `just properties-check` — every failure names the file and the row to add. + +A property that must not appear in IntelliSense goes in +`src/tests/BuildSdk.IntegrationTests/PropertySchema/SdkPropertyClassification.cs` with a reason: +`ExternallyOwned` (MSBuild/.NET/NuGet), `Internal` (unprefixed implementation detail), +`ComputedDefaults` (machine- or project-dependent value). Prefer a `_` prefix for anything new and +internal — then no list is needed. + +## The rule this exists for + +A property is written down in several places, and only some of them are checked by a test. The failure +mode this repository keeps hitting is a change that lands in one place and stops there — the property +declaration, its validation, `README.md`, the wiki page and the shipped skills drift apart, and the +only evidence is a consumer following a doc that still describes last release's default. + +So: **treat a settings change as a sweep, not an edit.** Work the surfaces below, then prove it with +the validation commands at the end. + +## When to use this + +- Flipping a .NET 11 compiler feature default (`MemorySafetyRules`, `PurviewRuntimeAsync`). +- Adding, renaming, re-defaulting or removing a user-facing property. +- Making required settings, versions, target frameworks or compiler-visible settings clearer in the + docs and the shipped skills. +- Auditing whether a property is documented everywhere before you change it. + +For auditing a *consumer* repository, use `purview-buildsdk-repository-review`. For what a project +detection rule does, use `purview-buildsdk-project-detection`. + +## The surfaces — one property, many homes + +Find the row and make sure every file agrees. + +| Surface | File | What changes | +| -- | -- | -- | +| Runtime default (all projects) | `src/src/BuildSdk/Sdk/Props/Defaults.props` | The default when unset, plus the explanatory comment. Sets the value in `Sdk.props`, which is also what makes it compiler-visible. | +| Per-TFM default | `src/src/BuildSdk/Sdk/Targets/Modern.targets` | The `net11.0`+ feature selection. Must agree with `Defaults.props`. | +| Property schema | `src/src/BuildSdk/Sdk/Sdk.props.buildschema.json` | Description, type and `defaultValue` — what csproj IntelliSense shows. `PropertySchemaTests` fails when it disagrees with the code or the docs. | +| Compiler-visible description | `src/src/BuildSdk/Sdk/Sdk.props` | The `CompilerVisibleProperty` ``, which is what an analyzer author reads. | +| Pinning test | `src/tests/BuildSdk.IntegrationTests/Dotnet11FeaturesTests.cs` | The test that fails when the default changes silently. | +| Changelog | `CHANGELOG.md` | An entry under `## [] - `: `### Changed` for behaviour, `### Documentation` for docs-only. Never `[Unreleased]` during active development — a merge to `main` publishes the version, so the entry would read as unreleased until it was already live. `[Unreleased]` is only for a pre-release version (`-alpha`, `-beta`, `-rc`). | +| README | `README.md` | The feature table row, the property table(s), the quick start, and the `.NET 11 features` section + tiers paragraph. | +| Wiki | `docs/wiki/Configuration-Reference.md`, `Getting-Started.md`, `configuration/Version-Detection.md`, `concepts/Project-Type-Detection.md` | The property tables, the required-settings and target-framework sections, and the compiler-visible section. | +| Shipped skills | `skills/purview-buildsdk-*/SKILL.md` and `references/*.md` | The property tables and the sections below. | +| AGENTS.md | `AGENTS.md` | The "authored set, and what each one answers" table, when a skill's scope grows. | + +Two traps worth naming: + +- **The default lives in two places.** `Defaults.props` defaults the property for every project (which + is what exposes it to the compiler); `Modern.targets` re-defaults it per-TFM. Change one and the + other silently disagrees. +- **`Sdk.props` vs `Sdk.targets` decides *where* a value may be set.** A property read during + `Sdk.props` evaluation must be set before the import; everything else can be set in the project. Say + which it is, in the docs, next to the property. + +A **feature** bigger than a single property gets its own guide as well. SourceLink is the worked +example, because the decision is per repository host: add a wiki page in its topic folder +(`docs/wiki/packaging/SourceLink.md`) and register it in `_Sidebar.md` **and** that section's table in +`Home.md` — `DocumentationWikiTests` fails until both list it — add a `## ` section to `README.md`, and — when +it is configuration — a `references/.md` under +`skills/purview-buildsdk-configuration/` that the `SKILL.md` links. Fix any wrong package or property +name you find while documenting: the SourceLink pages used to name `Microsoft.SourceLink.AzureDevOps.Git`, +which does not exist. + +## Worked example — flip a .NET 11 compiler feature default + +1. `Defaults.props` — change the default and the comment. Say **why** (an experimental compiler feature + is opt-in), not just what. +2. `Modern.targets` — change the same default in the per-TFM block and its header comment. +3. `Sdk.props` — the `CompilerVisibleProperty` `` ("0 off (default), …"). +4. `Dotnet11FeaturesTests.cs` — make the default test assert the switch is **absent**, rename it to say + so, and add an explicit opt-in test. De-couple any other test that asserted the switch was on by + default (for example, the project-features-survive test). +5. `README.md` — the `.NET 11 opt-ins` row, the `.NET 11 features` table row and the tiers paragraph, + and the property table row. +6. `docs/wiki/Configuration-Reference.md` — the General row **and** the `.NET 11 features` table. +7. Skills — the `MemorySafetyRules`/`PurviewRuntimeAsync` rows in + `skills/purview-buildsdk-configuration/SKILL.md` and `references/properties.md`. +8. `CHANGELOG.md` — a `### Changed` entry under the `package.json` version's heading that names the old default, the new one, how to + opt in, and what is unchanged. + +Keep `AllowUnsafeBlocks` (and anything else) at its compiler default: a feature flip must not quietly +change a second setting. + +## Required settings and versions — where it must be said + +Four values, stated as a table wherever a reader lands first: + +| Setting | Home | +| -- | -- | +| `NamespacePrefix` (required, pre-import) | `Directory.Build.props`, above the `` | +| SDK version | `global.json` → `msbuild-sdks.Purview.BuildSdk` (`PurviewBuildSdkVersionForGlobalJson` decides what the bootstrap writes) | +| Product version | repo-root `package.json` → `version` (`UsePackageJsonVersion`, `RootPackageJson`) | +| Package versions | `Directory.Packages.props` (CPM; a `PackageVersion` is a minimum) | + +Keep it identical in all four homes: `README.md` quick start, `docs/wiki/Configuration-Reference.md`, +`docs/wiki/Getting-Started.md`, and the `purview-buildsdk-adoption` and +`purview-buildsdk-configuration` skills. + +## Target framework(s) — where it must be said + +Every skill and doc that talks about configuring a project must explain how to set the target +framework(s), consistently: + +- the default set is `net10.0;net11.0` (`Current`); +- an explicit `TargetFramework`/`TargetFrameworks` always wins over every set, wherever it is set + except `Directory.Build.targets` (`Targets/TargetFrameworkOwnership.targets` reconciles it); +- a curated `PurviewTargetFrameworkSet` (`Current`, `Latest`, `Supported`, `Broad`, `All`) or a + redefined `PurviewTargetFrameworks` (pre-import, in `Directory.Build.props`); +- a Roslyn component keeps `netstandard2.0` and ignores the sets (`PurviewTargetFrameworkSetIgnored`). + +The canonical home is `skills/purview-buildsdk-configuration/SKILL.md` and its +`references/properties.md`; the other skills point at it rather than restating the whole thing. + +## Compiler-visible settings — where it must be said + +The `CompilerVisibleProperty` → `build_property.*` mechanism, plus a one-line +`AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue("build_property.X", …)` example, belongs in: + +- `skills/purview-buildsdk-configuration/SKILL.md` and `references/properties.md`, +- `skills/purview-buildsdk-project-detection/SKILL.md` (the analyzers section), +- `README.md`, `docs/wiki/Configuration-Reference.md` and `docs/wiki/concepts/Project-Type-Detection.md`. + +Say both halves: **consume** the exported property instead of re-deriving SDK behaviour, and **add a +new one** by listing it as a `CompilerVisibleProperty` item (with a ``) in `Sdk.props` — +nothing else exposes an MSBuild property to the compiler. + +## Skills sweep + +Each published skill that touches project configuration gets the change; a skill that is only about +skills tooling does not. + +| Skill | What to update | +| -- | -- | +| `purview-buildsdk-configuration` | required settings, target framework(s), compiler-visible properties, the property table, `references/properties.md` | +| `purview-buildsdk-adoption` | the required-settings table and `global.json` example, the target-framework step | +| `purview-buildsdk-project-layout` | the target-framework section | +| `purview-buildsdk-project-detection` | the target-framework subsection, the `build_property.*` note | +| `purview-buildsdk-repository-review` | the read list (`global.json`, `package.json`), the target-framework finding | +| `purview-buildsdk-testing` | the test-project target-framework paragraph | +| `purview-buildsdk-skill-tests`, `-skill-installation`, `-justfile` | **not** project configuration — leave them alone | + +Also update each skill's frontmatter `description` so the change makes the skill **fire** on the right +question ("setting the target framework(s)", "pinning the SDK version in global.json"). + +## Validate + +```bash +just build +just skills-validate # PRSKL0005, the pack-time check +just version-sync-check # the version pins must still match package.json +just lint-check # CSharpier over the repo +just properties-check # schema vs props, defaults, compiler-visible list, README and wiki tables + +# Targeted and fast (MTP: --treenode-filter, never --filter): +dotnet test --project src/BuildSdk.slnx --configuration Debug \ + --treenode-filter "/*/*/Dotnet11FeaturesTests/*" --ignore-exit-code 8 +dotnet test --project src/BuildSdk.slnx --configuration Debug \ + --treenode-filter "/*/*/(TargetFrameworkSetTests)|(PropertySchemaTests)|(CoreDefaultsTests)/*" --ignore-exit-code 8 +dotnet test --project src/BuildSdk.slnx --configuration Debug \ + --treenode-filter "/*/*/(RepositorySkillTests)|(RepositorySkillsSelfTests)|(PurviewSkillValidationTests)/*" --ignore-exit-code 8 +``` + +State the commands you ran before declaring the change done. + +## Guardrails + +- **Do not publish repository-local advice.** Anything under `skills/` ships to every consumer through + `npx skills add purview-dev/build-sdk`; guidance about working *in this repository* stays here, in + `.agents/skills/`, or in `AGENTS.md`. +- **Do not add a link into `.agents/skills/` from `AGENTS.md`** — it is gitignored, so the link is + broken in a fresh clone and `RepositorySkillsSelfTests` fails the build. +- **Do not give a local skill an authored skill's name.** + `AuthoredSkillsAreNotInstalledIntoTheAgentDirectories` fails when a folder here collides with one + under `skills/`. +- **Preserve a default unless the task explicitly changes product behaviour.** A docs task is not + licence to re-default anything. +- **Do not restate a default in a consumer project file** — recommend deleting the repetition instead. +- **Never force-add `.agents/skills/`.** Everything the CLI writes is reproducible from + `skills-lock.json`; `InstalledSkillsAreNotTracked` fails if any of it is committed. +- Keep changes small, use Conventional Commits, and do not mix an unrelated refactor into the sweep. + diff --git a/.agents/skills/purview-buildsdk-skill-installation/SKILL.md b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md index 5c2694dc..545eb9fa 100644 --- a/.agents/skills/purview-buildsdk-skill-installation/SKILL.md +++ b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md @@ -1,101 +1,86 @@ --- name: purview-buildsdk-skill-installation -description: Install, update, list, check and diagnose the agent skills a Purview.BuildSdk package ships. Use for the InstallPurviewSkills, UpdatePurviewSkills, CheckPurviewSkills, FixPurviewSkills, ListPurviewSkills, RecommendPurviewSkills and VerifyPurviewSkills MSBuild targets, for updating skills after a package upgrade, for choosing agents with PurviewSkillsAgents, for any PRSKL diagnostic, for choosing a package runner (bun x, npx, pnpm dlx, bunx, yarn dlx, deno), for skills-lock.json, or when a package ships skills but no agent can see them. +description: Install, update, check and tidy agent skills in a repository that uses Purview.BuildSdk, through the standard skills CLI (bun x skills). Use for the InstallPurviewSkills, UpdatePurviewSkills, CheckPurviewSkills and FixPurviewSkills MSBuild targets, for installing skills from a repository's root skills/ folder (owner/repo#ref), for restoring skills-lock.json in a fresh clone, for choosing agents with PurviewSkillsAgents, for any PRSKL diagnostic, for choosing a package runner (bun x, npx, pnpm dlx, bunx, yarn dlx, deno), or when an agent cannot see a skill that should be installed. license: MIT metadata: package: Purview.BuildSdk repository: purview-dev/build-sdk --- -# Installing the skills a package ships +# Installing agent skills For *authoring* skills, use `purview-buildsdk-skill-tests`; for `just` recipes that run these -targets, `purview-buildsdk-skills-justfile`. +targets, `purview-buildsdk-justfile`. ## The one thing to understand first -**The SDK writes nothing into an agent directory.** A package *ships* `.agents/skills//`, but -as metadata — it is what tells the SDK which skills the package offers and at which commit. Nothing -copies it out. +**The SDK is a thin wrapper over the standard skills CLI, and adds nothing bespoke.** A repository +publishes skills in its root `skills/` folder; the skills CLI installs them with +`bun x skills add owner/repo` and records each install in `skills-lock.json`. The SDK's targets only +run that CLI — from the repository root, once per solution, never during a build — and keep the +agent directories tidy. There is no package-to-skill mapping: a NuGet package carries .NET artifacts, +and skills come from git. -Skills are installed by the skills CLI and by nothing else. That is a deliberate single-writer rule. -Earlier versions of this SDK mirrored a package's whole `.agents/**` folder into the consuming -repository, and the CLI's own universal install location is `.agents/skills/` — the same paths. The -mirror would silently overwrite a pinned install on the next build, while `PRSKL0011` compared a -lockfile ref against content the mirror had already replaced. The mirror was removed outright rather -than fenced off, because the CLI covers the whole job. - -Installing is therefore a separate, deliberately **explicit** step. It is never a side effect of a -restore, a build, a design-time build or a test run. +The SDK writes nothing into an agent directory itself. The CLI is the single writer, because its +universal install location is `.agents/skills/` and a second writer on the same paths would overwrite +a pinned install. ```bash -dotnet msbuild -t:ListPurviewSkills # diagnostics only, installs nothing -dotnet msbuild -t:InstallPurviewSkills # delegates to the skills CLI -dotnet msbuild -t:UpdatePurviewSkills # re-add every skills-lock.json source: refresh, and add new skills -dotnet msbuild -t:CheckPurviewSkills # only the PurviewSkillsAgents directories exist -dotnet msbuild -t:FixPurviewSkills # remove the CLI's links from any other agent directory +dotnet msbuild -t:InstallPurviewSkills -p:PurviewSkillsSource=purview-dev/build-sdk # add a source +dotnet msbuild -t:InstallPurviewSkills # restore skills-lock.json (a fresh clone) +dotnet msbuild -t:UpdatePurviewSkills # re-add every lockfile source at its latest +dotnet msbuild -t:CheckPurviewSkills # only the PurviewSkillsAgents directories exist +dotnet msbuild -t:FixPurviewSkills # remove the CLI's links from any other agent directory ``` They run **only when named**. None has `BeforeTargets` or `AfterTargets`, nothing in the SDK depends on them, and `InstallPurviewSkills`, `UpdatePurviewSkills` and `FixPurviewSkills` refuse to run when -something else pulls them into a build, a restore or a design-time build (`PRSKL0009`). Do not hook -them into a build, a `*DependsOn` property or a git hook. If skills are changing on their own, -something has been wired up that should not be. - -Name **the solution**. Skills belong to the repository, so each target runs once however many -projects the solution holds: every project hands the work to the same elected leader, and MSBuild -runs it once. Package resolution reads every project in the solution, so a package only one project -references is still found. A single project works too, but sees only its own packages. For `just` -recipes that run these targets, see `purview-buildsdk-skills-justfile`. +something pulls them into a build, a restore or a design-time build (`PRSKL0009`). A git hook or a +`just` recipe dependency that runs one on its own is not caught by that — do not wire them up that way. -## How a skill is resolved, and why the version is always right +Name **the solution**. Skills belong to the repository, so each target runs once however many projects +the solution holds: every project hands the work to the same elected leader, and MSBuild runs it once. +A single project works too. -There is deliberately **no custom manifest**. A package that ships skills already carries both -halves of the mapping, and `ResolvePurviewSkillSources` reads them: +## Installing -- `.agents/skills//` in the restored package says *which* skills it ships. -- The nuspec's ``, written by SourceLink for every package this - SDK builds, says which git repository and which **exact commit** produced them. +| Command | Runs | +| -- | -- | +| `-t:InstallPurviewSkills -p:PurviewSkillsSource=owner/repo` | `skills add "owner/repo" --skill "*"` — every skill in that repository's root `skills/` | +| `-t:InstallPurviewSkills -p:PurviewSkillsSource=owner/repo#` | the same, pinned to a commit, tag or branch | +| `-t:InstallPurviewSkills` | `skills experimental_install` — restore exactly what `skills-lock.json` records | -So the resolved ref is the commit the *restored package version* was built from. An older -`PackageReference` gets that version's skills, not whatever is on the default branch. Compatibility -is exact by construction rather than by convention. +The caller owns the ref: append `#` to pin it. With no source and no `skills-lock.json` there is +nothing to install, which is `PRSKL0007`. `bun x skills add` works just as well run directly; the +target exists so a repository uses one runner, one root and one agent selection everywhere. ## Updating ```bash -# after bumping the PackageReference -dotnet restore -dotnet msbuild -t:UpdatePurviewSkills +dotnet msbuild -t:UpdatePurviewSkills -p:PurviewSkillsDryRun=true # see the commands first +dotnet msbuild -t:UpdatePurviewSkills ``` -`UpdatePurviewSkills` re-adds every unique source recorded in `skills-lock.json` with `skills add`: - -- a source a restored package ships is re-added at **that package's commit**, naming every skill - that package version ships — the same pinned `add` `PRSKL0011` recommends, so the skills match the - package version referenced; -- every other source is re-added at its latest with `--skill "*"`. - -That is the whole update. `skills add` reinstalls every skill it selects, so one re-add both -refreshes the installed skills and adds any the source has gained; `skills update` would only -re-fetch what the lockfile already lists, and is never run. `-p:PurviewSkillsDryRun=true` prints every -command and runs none. Each failed source is named (`PRSKL0008`). - -`InstallPurviewSkills` also works as an update for package skills alone: it re-adds each at its -package's commit. Do not reach for a bare `skills update` instead; see below. +`UpdatePurviewSkills` re-adds every unique source recorded in `skills-lock.json` with +`skills add "" --skill "*"`, at its latest. That is the whole update: `skills add` reinstalls +every skill it selects, so one re-add both refreshes the installed skills and adds any the source has +gained, whereas `skills update` would only re-fetch what the lockfile already lists, and is never run. +Every source is attempted even when one fails, and each failure is named (`PRSKL0008`). No lockfile is +`PRSKL0007`. ## Choosing agents `PurviewSkillsAgents` is **empty by default**, which passes no `--agent` and lets the CLI auto-detect -the agents on the machine. Set it to choose — in a project file `universal`, -or on a command line with the separator escaped or the value quoted, because `dotnet msbuild -p:` -itself splits on `,` and `;`: +the agents on the machine. Set it to choose — in `Directory.Build.props` +(`universal`), or on a command line with the separator +escaped, because `dotnet msbuild -p:` itself splits on `,` and `;`: ```bash -dotnet msbuild -t:UpdatePurviewSkills -p:PurviewSkillsAgents=claude-code%2Ccodex +dotnet msbuild -t:UpdatePurviewSkills -p:PurviewSkillsAgents=claude-code%2Ccodex ``` -Each agent becomes its own `--agent` flag; the CLI does not split a comma-separated value. +Each agent becomes its own `--agent` flag; the CLI does not split a comma-separated value. A restore +(`experimental_install`) is passed no agent flag. When `PurviewSkillsAgents` is set, `CheckPurviewSkills` fails (`PRSKL0015`) for any agent skill directory at the repository root that belongs to an agent it does not name. `.agents/skills` is always @@ -112,85 +97,21 @@ CLI's own agent table; an agent it does not know is `PRSKL0016`, fixed by adding With `PurviewSkillsAgents` empty, both targets say so and do nothing: every agent directory is then a legitimate destination. -## Installing from an explicit source - -For a skill you want but have no `PackageReference` for: - -```bash -dotnet msbuild -t:InstallPurviewSkills -p:PurviewSkillsSource=purview-dev/build-sdk -``` - -The caller owns the ref here — append `#` to pin it. This path needs no restore, so -`PRSKL0001` does not apply. - ## Diagnostics | Code | Meaning | Fix | | -- | -- | -- | -| `PRSKL0001` | `project.assets.json` is missing | Restore. Resolution reads the restore graph and deliberately does not trigger a restore of its own | -| `PRSKL0002` | The package's nuspec has no GitHub repository URL, or no commit | The package cannot be pinned. It is reported rather than installed from a guessed branch | -| `PRSKL0003` | No repository root resolved | Pass `-p:PurviewSkillsInstallRoot=` | +| `PRSKL0003` | No repository root resolved | Pass `-p:PurviewSkillsInstallRoot=`, or add an `AGENTS.md` at the root | | `PRSKL0004` | No skills CLI and no package runner found | Install Bun or Node, or set `PurviewSkillsCommand` | | `PRSKL0005` | An authored skill is malformed | See `purview-buildsdk-skill-tests` | -| `PRSKL0006` | Retired, with `PurviewSkillsUpdateMode` | Remove the property; update no longer runs `skills update` | -| `PRSKL0007` | `UpdatePurviewSkills` found no `skills-lock.json` | Install first with `-t:InstallPurviewSkills` | +| `PRSKL0007` | Nothing to install or update: no `PurviewSkillsSource`, and no `skills-lock.json` | Install from a source first | | `PRSKL0008` | Re-adding a lockfile source failed | Read the CLI output above it | | `PRSKL0009` | Install, update or fix was pulled into a build, a restore or a design-time build | Remove the hook; run the target on its own | -| `PRSKL0010` | A skill a referenced package ships is missing | Warning. `bun x skills add ...` | -| `PRSKL0011` | An installed skill is pinned to a different ref than the referenced package | Warning. Install the package's own commit | -| `PRSKL0012` | A skill cannot be verified | Message | -| `PRSKL0013` | An orphaned lockfile entry | Message | -| `PRSKL0014` | Recorded in the lockfile but never restored | Message | | `PRSKL0015` | An agent skill directory exists for an agent `PurviewSkillsAgents` does not name | `-t:FixPurviewSkills`, then remove anything it kept by hand | | `PRSKL0016` | `PurviewSkillsAgents` names an agent with no known skills directory | Warning. Add a `PurviewSkillsAgentDirectory` item | -| `PRSKL0017` | Projects in the solution reference one skill-shipping package at different versions | Warning. Align the versions; only one version's skills can be installed | - -## Nothing about skills runs during a build -This used to be the opposite: a `RecommendPurviewSkillsOnBuild` hook ran on `AfterTargets="Build"`. -It was removed after measurement — on a 39-project solution it read and regex-scanned every project's -`project.assets.json` (≈500 KB, 256 libraries each), made thousands of failing `Directory.Exists` -calls, read the lockfile and walked the repository root, on every build, to print advice that a -repository managing its own skills had already acted on. All of its inputs are repository-scoped, so -the per-project cost bought nothing. - -The work is now paid only when somebody names a target: - -```bash -dotnet msbuild -t:RecommendPurviewSkills # report, never fail -dotnet msbuild -t:VerifyPurviewSkills # report, and fail on PRSKL0010/0011 -``` - -`EvaluatePurviewSkillState` joins the skills each restored package ships against the CLI's own -`skills-lock.json`. Both inputs are local files, so it stays offline and spawns no process. - -`VerifyPurviewSkills` is the gate to call from CI. It escalates only `PRSKL0010` (missing) and -`PRSKL0011` (drifted) to errors: both are factual, both are fixed by one command, and neither can fire -without a referenced package that really ships skills. `PRSKL0012` stays a warning because a package -published without repository metadata is not the consumer's to fix, and `PRSKL0013`/`PRSKL0014` stay -messages. Failing over something the caller cannot clear is how a gate gets switched off. - -Three things shape the output: - -- Missing and drifted are **warnings** (fixable in one command); unverifiable and orphaned are - **messages**. `VerifyPurviewSkills` turns missing and drifted into failures, and promotes - unverifiable to a warning. -- A correctly installed set produces no output at all. -- There is no deduplication stamp any more. `EnablePurviewSkillRecommendations`, - `PurviewSkillRecommendationsInCI` and `PurviewSkillRecommendationStampPath` were retired with the - hook: a stamp existed so a per-build hook could report one state once per repository, and a target - a person invokes should answer every time it is asked. - -Orphan detection is **owner-scoped on purpose**: a lockfile cannot record who asked for an entry, so -only an entry under an owner the project references — but from a repository it no longer references -— is reported. A skill from any other owner is the developer's own, and is never mentioned. - -### Drift never recommends a bare `skills update` - -Re-adding a source without a ref installs the *newest* published skill, which is only correct when -the newest skill happens to describe the referenced package version. `PRSKL0011` names the referenced -package's own commit in a pinned `skills add` instead — which is what `-t:InstallPurviewSkills` and -`-t:UpdatePurviewSkills` run for package skills. +`PRSKL0001`, `PRSKL0002`, `PRSKL0006`, `PRSKL0010`–`PRSKL0014` and `PRSKL0017` are retired: they +belonged to the package-to-skill mapping and recommendation targets, which are gone. ## Package runners @@ -205,17 +126,17 @@ The CLI is published to npm, and every runner spells "run this npm package" diff | `pnpm` | `pnpm dlx skills@1` | | `yarn` | `yarn dlx skills@1` — Yarn 2+ only | | `deno` | `deno run -A npm:skills@1` | +| `skills` | `skills` — already on `PATH`, no download | -An unknown runner falls through to ` $(PurviewSkillsPackage)`, so it still works. +An unknown runner falls through to ` $(PurviewSkillsPackage)`, so it still works. The launcher +pins the CLI to a major (`PurviewSkillsPackage`, default `skills@1`). `DetectPurviewSkillsCli` probes `PATH` for `skills` first, then `$(PurviewSkillsRunner)`, then `$(PurviewSkillsRunnerCandidates)`, honouring `PATHEXT` on Windows where these are `.cmd` shims. `PATH` is read directly rather than by spawning a version check: deterministic, no process, and it cannot hang on a cold download. **The runner actually found wins over the configured preference.** -`PurviewSkillsCommand` overrides the launcher entirely and skips detection. - -`ListPurviewSkills` reports runner availability but **never** errors on its absence — resolving and -reporting needs no Node tooling at all. +`PurviewSkillsCommand` overrides the launcher entirely and skips detection. A dry run with no runner +on `PATH` prints the configured runner's command rather than failing. ## Always the repository root @@ -230,8 +151,7 @@ skill target runs from one resolved root: 6. SourceLink's git `SourceRoot` There is deliberately **no project-directory fallback**. That would scatter a lockfile and an agent -directory through the tree, one per project. An unresolved root is `PRSKL0003` on install, and -silence for recommendations — a build never fails over advice. +directory through the tree, one per project. An unresolved root is `PRSKL0003`. The marker walk is described in [references/repository-root.md](references/repository-root.md); its two-pass behaviour and its home-directory bound are both load-bearing. @@ -239,10 +159,6 @@ two-pass behaviour and its home-directory bound are both load-bearing. ## Why the CLI rather than a reimplementation The CLI owns the agent-specific destinations and their discovery conventions, which differ per agent -and change independently of this SDK. It also owns `skills-lock.json`, so a repository can restore -the same set later with `bun x skills experimental_install`. - -The lockfile is read **directly** rather than by parsing `skills list --json`, because that -listing omits the `ref` — and the ref is the whole compatibility question. Reading a file also needs -no Node tooling, which is what keeps recommendations working on a machine that can build .NET but -has no Node. There is deliberately no competing lockfile. +and change independently of this SDK. It also owns `skills-lock.json`, which is what lets a fresh +clone restore the same set with `-t:InstallPurviewSkills` (`skills experimental_install`). There is +deliberately no competing lockfile and no competing metadata. diff --git a/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md index d5561745..64cd1c49 100644 --- a/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md +++ b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md @@ -43,8 +43,8 @@ an agent directory somewhere that is not the repository. ## A target's Condition is evaluated before its DependsOnTargets A property a dependency computes is still empty in the depending target's `Condition`. That is why -the unresolved-root guard sits on the `EvaluatePurviewSkillState` task rather than on -`RecommendPurviewSkills`, and why the explicit-only guard (`PRSKL0009`) is a dependency target with +the unresolved-root check (`PRSKL0003`) is an `Error` inside a dependency target rather than a +`Condition` on the public target, and why the explicit-only guard (`PRSKL0009`) is a dependency target with its own `Error` rather than a `Condition` on `InstallPurviewSkills`, `UpdatePurviewSkills` or `FixPurviewSkills`. @@ -52,6 +52,7 @@ its own `Error` rather than a `Condition` on `InstallPurviewSkills`, `UpdatePurv `Purview.Skills.Testing` ships `SkillRepositoryRoot`, a managed port of the same walk with the same default markers, so a test that validates a repository's skills resolves the same root the build -installs them into. The two are kept in step by `SkillRepositoryRootTests`, including an assertion -on the default marker list itself — both are defaults nobody sets, so nothing else would notice them -drifting apart. +installs them into. In the Purview.BuildSdk repository the two are kept in step twice over: +`SkillRepositoryRootParityTests` runs the real MSBuild target and the managed port over the same +folders and compares the root and the matched marker, and `SkillRepositoryRootTests` pins the default +marker list itself — both are defaults nobody sets, so nothing else would notice them drifting apart. diff --git a/.agents/skills/purview-buildsdk-skill-tests/SKILL.md b/.agents/skills/purview-buildsdk-skill-tests/SKILL.md index 4043ac4f..e6def7d3 100644 --- a/.agents/skills/purview-buildsdk-skill-tests/SKILL.md +++ b/.agents/skills/purview-buildsdk-skill-tests/SKILL.md @@ -15,7 +15,6 @@ For installing skills a package ships, use `purview-buildsdk-skill-installation` **At the repository root, in `skills//SKILL.md`.** Always. -The skills CLI only discovers skills at a root `skills/` folder, and it reaches a repository by The skills CLI only discovers skills at a root `skills/` folder, and it reaches a repository by cloning it, so nothing else is visible to it. Nothing is packed into a NuGet package — a package carries .NET artifacts, and skills are delivered by the CLI from git. @@ -108,8 +107,8 @@ Overrides: `RepositoryRoot` (default: the SDK's repository-root marker walk), `SkillsFolderName` (default `skills`), and `SkipRule(string)`. The evaluator takes a `SkillCatalogue`, so a rule can be exercised against a fixture folder rather -than a real repository — the same three-layer shape this repository uses throughout: a reader, an -evaluator over an in-memory model, and a thin test base class. +than a real repository: a reader (`SkillManifestReader`/`SkillCatalogue`), an evaluator over the +in-memory model (`SkillPolicyEvaluator`), and a thin test base class (`PurviewSkillTests`). ### At pack time — `ValidatePurviewSkills` diff --git a/.agents/skills/purview-buildsdk-skill-tests/references/authoring.md b/.agents/skills/purview-buildsdk-skill-tests/references/authoring.md index 69d4ee45..03d9bbf8 100644 --- a/.agents/skills/purview-buildsdk-skill-tests/references/authoring.md +++ b/.agents/skills/purview-buildsdk-skill-tests/references/authoring.md @@ -77,3 +77,5 @@ stale skill looks broken — it just gives confidently wrong advice. behaviour; those are greppable when the code changes. - Where a number or a list is authoritative somewhere else — a default, an enumeration — say where it comes from, so a reader can check it. +- To bring skills, agent instructions, READMEs and docs back in line with the code after a change, + use `purview-buildsdk-docs-sync`. diff --git a/.agents/skills/purview-buildsdk-testing/SKILL.md b/.agents/skills/purview-buildsdk-testing/SKILL.md index 5a5a7d53..c7a79950 100644 --- a/.agents/skills/purview-buildsdk-testing/SKILL.md +++ b/.agents/skills/purview-buildsdk-testing/SKILL.md @@ -1,6 +1,6 @@ --- 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. +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 or matching its target framework to its subject, 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 @@ -27,12 +27,18 @@ With the defaults (`TestingFramework=TUnit`, `SubstituteFramework=TUnitMocks`, contract — `IDE0040`, field naming, formatting, `IDE1006` - `IsPackable=false`, `IsPublishable=false` +A test project inherits the SDK's default target framework set (`net10.0;net11.0`). When it must match +its subject — a project still on one TFM, or a subject that multi-targets — set +`TargetFramework`/`TargetFrameworks` on the **test** project (an explicit value always wins), or select a +`PurviewTargetFrameworkSet`. See `purview-buildsdk-configuration`. + Add nothing else unless the project's purpose demands it. Two that stay explicit: - `TUnit.Aspire` — Aspire lifecycle and AppHost-backed integration tests - `Purview.Containers` — container-backed integration tests. One API over Microsoft WSL Containers (no Docker install needed on Windows) or Docker, with typed modules for SQL Server, PostgreSQL, - Redis, MySQL, RabbitMQ, Azurite and NATS. Experimental and prerelease, so pin the version. + Redis, MySQL, RabbitMQ, NATS, Azurite and the Azure Key Vault and Service Bus emulators. + Experimental and prerelease, so pin the version. Reference `Testcontainers` directly only where no typed module exists yet. ## Authoring rules @@ -83,7 +89,7 @@ they are what `--treenode-filter` property predicates select on. ```bash dotnet test # the whole suite dotnet test --treenode-filter "/*/*/LoginTests/*" # one class -dotnet test --treenode-filter "/*/*/*/*[Category=Unit]" +dotnet test --project --treenode-filter "/*/*/*/*[Category=Unit]" ``` Prefer the repository's own task (`just test`, or equivalent) for a full run. @@ -108,7 +114,8 @@ In order: ## Cross-platform requirement -CI runs the full suite on Linux, so a test that only passes on Windows is a failing change. +A test that only passes on Windows fails on a Linux CI runner, so write every test to pass on Windows, +Linux and macOS. - Never hardcode a platform-specific path — no `C:\...`, no backslash-only separators, no case-insensitive path assumptions. diff --git a/.agents/skills/purview-site-assessment/SKILL.md b/.agents/skills/purview-site-assessment/SKILL.md new file mode 100644 index 00000000..64afe45b --- /dev/null +++ b/.agents/skills/purview-site-assessment/SKILL.md @@ -0,0 +1,109 @@ +--- +name: purview-site-assessment +description: Assess how well a Purview repository integrates with purview.dev and recommend fixes - runs a dependency-free script over the checkout (docs layout and hierarchy, root page, slugs and collisions, titles, broken links and images, ASCII-art and unlabelled Mermaid diagrams, purview:likec4 diagram pairs and their .c4 models, _redirects.yml and page moves in git history that left no redirect, agent skills front matter), cross-checks the site's catalogue entry and the live GitHub metadata, and produces a prioritised report with a concrete fix for each finding. Use before a repository joins purview.dev, after reorganising docs, when the site shows something wrong for the project, or when asked to audit or "assess" site integration. +license: MIT +metadata: + site: https://purview.dev + repository: purview-dev/purview-dev +--- + +# Assessing a repository's purview.dev integration + +The rules being checked are in `purview-site-docs` (how documentation is written) and +`purview-site-integration` (everything else the site reads). This skill finds where a repository +departs from them, ranks the findings, and proposes fixes. It **reports first and edits only when +asked**; and it never edits `purview-dev/purview-dev` — catalogue changes are proposed as a snippet for +the site maintainers. + +## 1. Fetch the catalogue entry (when the project is on the site) + +The entry carries contracts the repository must keep: the docs path, the root page, every use case's +`docsPage` deep link, and any redirects recorded on the site side. + +```shell +curl -sfL https://raw.githubusercontent.com/purview-dev/purview-dev/main/src/src/data/projects/.yml -o .purview-entry.yml +``` + +`` is normally the repository name (`unions`, `build-sdk`, `pm-skills`). A 404 means the project is +not on the site yet — assess without it and include the entry proposal from +`purview-site-integration` (references/catalogue-contract.md) in the report. Delete +`.purview-entry.yml` when done; never commit it. + +## 2. Run the script + +```shell +node /scripts/assess.mjs . --catalogue .purview-entry.yml +``` + +`` is where the skill was installed (for example `.agents/skills/purview-site-assessment`). +Options: `--docs ` to name the docs folder (default `docs/wiki`, then `docs`), `--json` for +machine-readable output. It reads files and `git log` only, needs Node 18+, and exits 1 when an +error-level finding exists, so it can also run in CI. + +Every rule id it prints is explained, with how to fix it, in [references/rules.md](references/rules.md). + +## 3. Check what the script cannot see + +```shell +gh api repos/purview-dev/ --jq '{description, topics, archived, default_branch, has_discussions}' +gh api "repos/purview-dev//releases?per_page=5" --jq '.[] | {tag_name, prerelease, published_at}' +``` + +| Check | Finding when it fails | +| -- | -- | +| 6–15 topics, lower-case and hyphenated | **META-001** warning — the card shows no or weak tags | +| a description that agrees with the entry's `shortDescription` | **META-002** info | +| `default_branch` is `main` (or the entry says otherwise) | **META-003** error — the docs and edit links follow it | +| at least one release, tagged `vX.Y.Z` or `vX.Y.Z-prerelease.N`, matching the package version | **META-004** warning — no version is shown | +| entry `status` agrees: stable registry version → `stable`; prerelease only → `preview`; archived repository → `archived` | **META-005** warning | +| the repository is not archived while the entry says `stable`/`preview` | **META-006** error | + +Then read what a script cannot judge: + +- **Hierarchy.** Do the topic folders match how a reader looks for things? Are pages in the folder + for their topic? A DOCS-003 finding needs a proposed tree, not just "add folders". +- **Diagrams.** For each DIAG-001 (ASCII art) and DIAG-008 (Mermaid in a text fence), decide whether it + is an architecture or flow diagram (→ LikeC4 model + Mermaid pair) or a small sketch (→ a labelled + ```` ```mermaid ```` fence is enough). Draft the model for the most important one. +- **README vs docs.** Does the README point into the docs, or duplicate them? +- **Skills.** Does each `description` say what the skill does *and when to use it*? + +## 4. Report + +Lead with a one-paragraph verdict, then the findings grouped by priority, each with the fix written +out — the redirect line, the renamed path, the converted fence — not just the rule: + +```markdown +# purview.dev integration: + + + +## Blockers (break the site's sync or build) +| Rule | Where | Problem | Fix | + +## Should fix (readers see something wrong) +| Rule | Where | Problem | Fix | + +## Improvements +| Rule | Where | Problem | Fix | + +## Proposed changes +- `docs/wiki/_redirects.yml` — the full file. +- The proposed docs tree, when DOCS-003 fired. +- One converted diagram pair (model + Mermaid), when DIAG-001/008 fired. +- The catalogue entry snippet for the site maintainers, when the entry is missing or drifts. +``` + +Blockers are every `error`; "should fix" is every `warning` plus META errors; improvements are +`info`. Collapse repeated findings into one row with a count and the list of places. + +## 5. Fixing (only when asked) + +Work in this order — each step changes what the next one sees: + +1. Blockers: unclosed fences, broken links and images, slug collisions, missing deep-link pages. +2. Redirects for moves already made (RED-004), before any further move. +3. Reorganise a flat tree into topic folders (`git mv`), adding each move to `_redirects.yml` and fixing + inbound links in the same commit. Leave catalogue deep-linked pages at the root. +4. Convert diagrams. Validate each model with `npx likec4 validate `. +5. Rerun the script; the report should show no errors, and only warnings you have explained. diff --git a/.agents/skills/purview-site-assessment/references/rules.md b/.agents/skills/purview-site-assessment/references/rules.md new file mode 100644 index 00000000..245e5853 --- /dev/null +++ b/.agents/skills/purview-site-assessment/references/rules.md @@ -0,0 +1,79 @@ +# Assessment rules + +Severity is what the finding costs: an **error** breaks the site's sync or build, or loses content; a +**warning** is visible to readers; an **info** is an improvement. + +## Repository + +| Rule | Severity | Finding | Fix | +| -- | -- | -- | -- | +| REPO-001 | error | no `README.md` | add one: what it is, install, a minimal example, a link into the docs | + +## Documentation + +| Rule | Severity | Finding | Fix | +| -- | -- | -- | -- | +| DOCS-001 | warning / info | no docs folder; warning when the README is long enough to be a docs tree (≥ 8 `##` sections or ≥ 300 lines) | create `docs/wiki/` with topic folders; keep the README as the front door | +| DOCS-002 | warning | no `Home.md`/`index.md`/`README.md` at the docs root | add `Home.md`, or have the entry set `docs.rootPage` | +| DOCS-003 | warning / info | a flat folder of 7+ pages, or 10+ pages left at the root beside folders | group into topic folders; keep only the hub, getting-started and deep-linked pages at the root | +| DOCS-004 | info | folders more than two levels deep | flatten unless a sub-area has several pages of its own | +| DOCS-005 | error / warning | a path that reduces to an unpublishable slug (error), a name the site has to rewrite (warning), or a page or folder named `prerelease` (error) | letters, digits and single dashes | +| DOCS-006 | error | two files publish at the same URL | rename one | +| DOCS-007 | warning | no `#` title, or more than one | exactly one `# Title` first | +| DOCS-008 | error | a relative link to a file that does not exist | fix the path; it resolves from the page's own folder | +| DOCS-009 | error | a relative image that does not exist | fix the path or add the image | +| DOCS-010 | info | a fence with no language | label it | +| DOCS-011 | info / warning | no `_Sidebar.md` (info), a sidebar link to a missing page (warning), a page not in the sidebar (info) | list every page once, by section | +| DOCS-012 | info | `mkdocs.yml` present | delete it; the site ignores it | +| DOCS-013 | error | a code fence never closed | close it | + +## Diagrams + +| Rule | Severity | Finding | Fix | +| -- | -- | -- | -- | +| DIAG-001 | warning | ASCII-art diagram in a text fence | LikeC4 model + Mermaid fence in a `purview:likec4` block; legends become tables | +| DIAG-002 | info | a ```` ```mermaid ```` fence with no LikeC4 pair | fine as is; pair architecture and flow diagrams | +| DIAG-003 | warning | a bare ```` ```likec4 ```` fence — GitHub shows source | move the model to a `.c4` file and use a pair | +| DIAG-004 | error | a malformed pair: unclosed or orphan marker, no `file`, a model that is missing or outside the repository, or a `view` the model does not declare | fix the marker or the model; the site shows the Mermaid until then | +| DIAG-005 | warning | a model that imports or has no `specification` | one self-contained file per diagram | +| DIAG-006 | warning | a pair with no Mermaid fence, so GitHub shows nothing | add the Mermaid rendering between the markers | +| DIAG-007 | info | a pair that does not name its `view` | add `view=""` | +| DIAG-008 | warning | Mermaid source inside a ```` ```text ```` fence | label it ```` ```mermaid ````; both renderers draw it | + +The text-fence classifier skips folder trees (`├──`, `└──`), which are listings rather than diagrams. + +## Redirects + +| Rule | Severity | Finding | Fix | +| -- | -- | -- | -- | +| RED-001 | error | `_redirects.yml` unreadable, or a pair that cannot become a URL or points at itself — the site's sync fails | one `previous: current` per line under `redirects:` | +| RED-002 | warning | a destination that is not a page | point at the page's current path | +| RED-003 | info | a source that is still a page, so the redirect is inactive | expected while the move is unreleased | +| RED-004 | warning | a page renamed or deleted in git history with no redirect (here or in the catalogue's `docs.redirects`) | add the pair the finding suggests, unless the page was never published | +| RED-005 | warning | a chain (`a → b`, `b → c`) | point every old slug at the final page | + +RED-004 reads the last 300 commits that touched the docs folder. A page that existed only between two +site builds was never published; ignore those. + +## Catalogue (with `--catalogue`) + +| Rule | Severity | Finding | Fix | +| -- | -- | -- | -- | +| CAT-001 | error | the entry's `docs.path` (or `--docs`) has no Markdown | create it, or correct the entry | +| CAT-002 | error | the entry's `docs.rootPage` does not exist | restore the page, or change the entry in the same release | +| CAT-003 | error | a use case's `docsPage` is not a page — the site's build fails | keep the page at the root under its name, or move the deep link | +| CAT-004 | info | the entry carries `docs.redirects` | move them into `_redirects.yml`, then drop them from the entry | + +## Agent skills + +| Rule | Severity | Finding | Fix | +| -- | -- | -- | -- | +| SKILL-001 | error | a `skills//` folder with no `SKILL.md` | add it, or move the folder | +| SKILL-002 | error | no front matter, or no `name` / `description` | add them | +| SKILL-003 | error | `name` differs from the folder | make them equal | +| SKILL-004 | warning | `name` is not a lower-case slug | rename | +| SKILL-005 | error | `SKILL.md` links a file that is not in the skill folder | ship it beside `SKILL.md` — only the folder is installed | + +## Metadata (checked by hand with `gh`) + +META-001 … META-006 are listed in `SKILL.md`, step 3. diff --git a/.agents/skills/purview-site-assessment/scripts/assess.mjs b/.agents/skills/purview-site-assessment/scripts/assess.mjs new file mode 100644 index 00000000..644c8b59 --- /dev/null +++ b/.agents/skills/purview-site-assessment/scripts/assess.mjs @@ -0,0 +1,669 @@ +#!/usr/bin/env node +// Assess a repository's readiness for purview.dev. +// +// node assess.mjs [repository-root] [--docs ] [--catalogue ] [--json] +// +// Reads files only (plus `git log`, when git is available). No network, no +// dependencies, Node 18+. Exit code 1 when an error-level finding exists. +// +// `--catalogue` is a local copy of this project's purview.dev catalogue entry +// (purview-dev/purview-dev: src/src/data/projects/.yml). With it, the +// docs path, root page, deep links and catalogue redirects are checked too. +// +// The rules mirror what purview.dev's docs sync does (purview-dev/purview-dev, +// src/src/lib/docs/). Each finding names a rule id the SKILL.md explains. + +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { basename, dirname, join, posix, relative, resolve, sep } from 'node:path'; + +// --------------------------------------------------------------------------- +// Arguments +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); +const json = args.includes('--json'); +const option = (name) => { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +}; +const VALUE_OPTIONS = new Set(['--docs', '--catalogue']); +const root = resolve( + args.find((arg, index) => !arg.startsWith('--') && !VALUE_OPTIONS.has(args[index - 1])) ?? '.', +); +const cataloguePath = option('--catalogue'); + +/** + * The few catalogue fields the checks need, read line by line from the entry's + * fixed layout (no YAML dependency): docs source/path/rootPage, docs.redirects + * and every use case's docsPage. + */ +function readCatalogueEntry(path) { + const lines = readFileSync(path, 'utf8').replace(/\r\n?/g, '\n').split('\n'); + const entry = { source: undefined, path: undefined, rootPage: undefined, redirects: {}, docsPages: [] }; + let section = null; + let sectionIndent = 0; + for (const line of lines) { + const indent = line.length - line.trimStart().length; + const text = line.trim().replace(/\s+#.*$/, ''); + if (text === '' || text.startsWith('#')) continue; + if (indent === 0) { + section = text === 'docs:' ? 'docs' : null; + continue; + } + if (section === 'docs' && text === 'redirects:') { + section = 'redirects'; + sectionIndent = indent; + continue; + } + if (section === 'redirects') { + if (indent <= sectionIndent) { + section = 'docs'; + } else { + const pair = /^["']?([^"':]+)["']?\s*:\s*["']?([^"']+?)["']?$/.exec(text); + if (pair) entry.redirects[pair[1].trim()] = pair[2].trim(); + continue; + } + } + if (section === 'docs') { + const field = /^(source|path|rootPage):\s*["']?([^"']+?)["']?$/.exec(text); + if (field && indent === 2) entry[field[1]] = field[2]; + } + const deepLink = /^docsPage:\s*["']?([^"'\s]+)["']?$/.exec(text); + if (deepLink) entry.docsPages.push(deepLink[1]); + } + return entry; +} + +const catalogue = cataloguePath ? readCatalogueEntry(resolve(cataloguePath)) : null; +const docsOverride = option('--docs') ?? (catalogue?.source === 'github-path' ? catalogue.path : undefined); + +/** @type {{ id: string; severity: 'error' | 'warning' | 'info'; file?: string; line?: number; message: string; fix: string }[]} */ +const findings = []; +const report = (id, severity, file, line, message, fix) => + findings.push({ id, severity, file, line, message, fix }); + +// --------------------------------------------------------------------------- +// File helpers +// --------------------------------------------------------------------------- + +const toPosix = (path) => path.split(sep).join('/'); +const repoPath = (absolute) => toPosix(relative(root, absolute)); +const read = (absolute) => readFileSync(absolute, 'utf8').replace(/\r\n?/g, '\n'); +const IGNORED_DIRS = new Set(['.git', 'node_modules', 'bin', 'obj', 'dist', '.vs', '.idea', 'artifacts']); + +function walk(directory, predicate, found = []) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const absolute = join(directory, entry.name); + if (entry.isDirectory()) { + if (!IGNORED_DIRS.has(entry.name)) { + walk(absolute, predicate, found); + } + } else if (predicate(absolute)) { + found.push(absolute); + } + } + return found; +} + +// --------------------------------------------------------------------------- +// Slugs — the same reduction purview.dev applies (src/src/lib/docs/links.ts) +// --------------------------------------------------------------------------- + +const reduceSegment = (segment) => + segment + .replace(/[^a-zA-Z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase(); + +function docSlug(relativePath) { + const segments = relativePath.split('/').filter((segment) => segment !== '' && segment !== '.'); + return segments + .map((segment, index) => { + if (index < segments.length - 1) { + return reduceSegment(segment); + } + const name = segment.replace(/\.(md|mdx)$/i, ''); + return segments.length > 1 && /^readme$/i.test(name) ? 'index' : reduceSegment(name); + }) + .join('/'); +} + +const SAFE_SEGMENT = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; +const isIndexName = (name) => /^(home|index|readme)\.md$/i.test(name); + +// --------------------------------------------------------------------------- +// Markdown helpers +// --------------------------------------------------------------------------- + +/** Lines with fenced code blanked (fences reported separately), so headings and links are read from prose only. */ +function scanFences(text) { + const lines = text.split('\n'); + const fences = []; + const prose = [...lines]; + let open = null; + lines.forEach((line, index) => { + const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); + if (!open && match) { + open = { start: index, run: match[1], info: match[2].trim(), body: [] }; + prose[index] = ''; + return; + } + if (open) { + if (match && match[1][0] === open.run[0] && match[1].length >= open.run.length && match[2].trim() === '') { + fences.push({ ...open, end: index }); + open = null; + } else { + open.body.push(line); + } + prose[index] = ''; + } + }); + if (open) { + fences.push({ ...open, end: lines.length - 1, unclosed: true }); + } + return { lines, prose, fences }; +} + +const stripInlineCode = (line) => line.replace(/`+[^`]*`+/g, ''); + +const MERMAID_START = /^\s*(flowchart|graph|sequenceDiagram|classDiagram|stateDiagram(-v2)?|erDiagram|gantt|journey|mindmap|timeline|C4Context|C4Container|C4Component|gitGraph|pie|quadrantChart|block-beta|architecture-beta)\b/; + +/** + * What an unlabelled or `text` fence holds: `mermaid` source (the old + * "Mermaid goes in a text block" convention), a folder `tree` (a listing, not a + * diagram), an ASCII-art `diagram`, or null for ordinary text. + */ +function classifyTextFence(fence) { + const language = fence.info.split(/\s+/)[0].toLowerCase(); + if (!['', 'text', 'txt', 'plaintext', 'ascii', 'none'].includes(language)) { + return null; + } + const body = fence.body.filter((line) => line.trim() !== ''); + if (body.length === 0) { + return null; + } + if (MERMAID_START.test(body[0])) { + return 'mermaid'; + } + const treeLines = body.filter((line) => /[├└]──|^\s*│|^\s*[\w.<>-]+\/\s*$/.test(line)).length; + if (treeLines >= Math.ceil(body.length / 2)) { + return 'tree'; + } + if (body.length < 3) { + return null; + } + const boxDrawing = body.some((line) => /[┌┐┘┬┴┼═║╔╗╚╝▶▼◀▲]/.test(line)); + const arrows = body.filter((line) => /(-{1,}>|<-{1,}|={1,}>|\+-{2,}|-{3,}\+|→|↓)/.test(line)).length; + const connectors = body.filter( + (line) => /^\s*[|^v+]\s*($|\s{2,})|\s{2,}[|^v+]\s*$/.test(line) || /\s[|^]\s{3,}[|^]/.test(line), + ).length; + return boxDrawing || (arrows >= 1 && connectors >= 1) || arrows >= 3 ? 'diagram' : null; +} + +// --------------------------------------------------------------------------- +// Diagram pairs — mirrors src/src/lib/docs/diagrams.ts +// --------------------------------------------------------------------------- + +const DIAGRAM_OPEN = /^ {0,3}\s*$/; +const DIAGRAM_CLOSE = /^ {0,3}\s*$/; + +function parseOptions(source) { + const options = {}; + for (const match of source.matchAll(/([A-Za-z][\w-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"']+))/g)) { + options[match[1]] = match[2] ?? match[3] ?? match[4] ?? ''; + } + return options; +} + +function declaredViews(source) { + return new Set( + [...source.matchAll(/(?:^|[\s{;])(?:dynamic\s+|deployment\s+)?view\s+([A-Za-z_][\w-]*)/g)].map((m) => m[1]), + ); +} + +function checkDiagramBlocks(file, scan) { + const page = repoPath(file); + const inFence = new Set(); + for (const fence of scan.fences) { + for (let index = fence.start; index <= fence.end; index++) inFence.add(index); + } + const blocks = []; + let open = null; + scan.lines.forEach((line, index) => { + if (inFence.has(index)) return; + const opening = DIAGRAM_OPEN.exec(line); + if (opening) { + if (open) { + report('DIAG-004', 'error', page, open.line, 'A purview:likec4 block is not closed before the next one opens.', 'Add `` after the Mermaid fence.'); + } + open = { line: index + 1, start: index, options: parseOptions(opening[1]) }; + } else if (DIAGRAM_CLOSE.test(line)) { + if (!open) { + report('DIAG-004', 'error', page, index + 1, 'A closing purview:likec4 marker has no opening marker.', 'Remove it, or add the opening marker with file="…" and view="…".'); + } else { + blocks.push({ ...open, end: index }); + open = null; + } + } + }); + if (open) { + report('DIAG-004', 'error', page, open.line, 'A purview:likec4 block is never closed.', 'Add `` after the Mermaid fence.'); + } + + for (const block of blocks) { + const inner = scan.fences.filter((fence) => fence.start > block.start && fence.end < block.end); + if (!inner.some((fence) => fence.info.split(/\s+/)[0] === 'mermaid')) { + report('DIAG-006', 'warning', page, block.line, 'The diagram block has no ```mermaid fence, so GitHub readers see no diagram.', 'Put the Mermaid rendering of the view between the markers.'); + } + const file = block.options.file; + if (!file) { + report('DIAG-004', 'error', page, block.line, 'The purview:likec4 marker has no file="…"; the site will show the Mermaid instead.', 'Add file="./diagrams/.c4" (relative to the page).'); + continue; + } + const target = file.startsWith('/') ? posix.normalize(file.slice(1)) : posix.normalize(posix.join(posix.dirname(page), file)); + if (target.startsWith('..')) { + report('DIAG-004', 'error', page, block.line, `file="${file}" leaves the repository.`, 'Keep the model inside the repository.'); + continue; + } + const absolute = join(root, target); + if (!existsSync(absolute)) { + report('DIAG-004', 'error', page, block.line, `file="${file}" does not exist (${target}); the site will show the Mermaid instead.`, 'Create the model or fix the path (it is relative to the page).'); + continue; + } + const model = read(absolute); + const views = declaredViews(model); + if (block.options.view && !views.has(block.options.view)) { + report('DIAG-004', 'error', page, block.line, `${target} declares no view "${block.options.view}" (declared: ${[...views].join(', ') || 'none'}).`, 'Name an existing view, or add it to the model.'); + } else if (!block.options.view) { + report('DIAG-007', 'info', page, block.line, 'The diagram block does not name a view, so the first view in the model is shown.', 'Add view="" so a new view added first does not change this page.'); + } + if (/^\s*import\s/m.test(model) || !/\bspecification\s*\{/.test(model)) { + report('DIAG-005', 'warning', target, 1, 'The model is not self-contained (it imports, or has no specification block). The site compiles each file on its own.', 'Give the file its own `specification`, `model` and `views`, and remove imports.'); + } + } + return blocks; +} + +// --------------------------------------------------------------------------- +// `_redirects.yml` — a tolerant reader for the simple shape the site documents +// --------------------------------------------------------------------------- + +function parseRedirects(text) { + const pairs = []; + const errors = []; + text.split('\n').forEach((raw, index) => { + const line = raw.replace(/\s+#.*$/, '').replace(/^#.*$/, ''); + if (line.trim() === '' || /^redirects:\s*$/.test(line.trim())) return; + const match = /^\s*(?:"([^"]+)"|'([^']+)'|([^:#\s][^:#]*?))\s*:\s*(?:"([^"]*)"|'([^']*)'|(.*?))\s*$/.exec(line); + if (!match) { + errors.push({ line: index + 1, message: `cannot read "${raw.trim()}" as \`previous: current\`` }); + return; + } + const from = match[1] ?? match[2] ?? match[3]; + const to = match[4] ?? match[5] ?? match[6]; + if (!to) { + errors.push({ line: index + 1, message: `"${from}" has no destination` }); + return; + } + pairs.push({ line: index + 1, from, to }); + }); + return { pairs, errors }; +} + +const redirectSlug = (value) => { + const trimmed = value.trim().replace(/^\/+|\/+$/g, ''); + return docSlug(/\.(md|mdx)$/i.test(trimmed) ? trimmed : `${trimmed}.md`); +}; + +// --------------------------------------------------------------------------- +// Repository-level checks +// --------------------------------------------------------------------------- + +if (!existsSync(root) || !statSync(root).isDirectory()) { + console.error(`Not a directory: ${root}`); + process.exit(2); +} + +const readmePath = ['README.md', 'readme.md', 'Readme.md'].map((name) => join(root, name)).find(existsSync); +if (!readmePath) { + report('REPO-001', 'error', undefined, undefined, 'No README.md at the repository root.', 'Add one: what the project is, install, a minimal example, a link into the docs.'); +} +for (const site of ['mkdocs.yml', 'mkdocs.yaml', 'docs/mkdocs.yml', 'docs/wiki/mkdocs.yml']) { + if (existsSync(join(root, site))) { + report('DOCS-012', 'info', site, undefined, `${site} is a site configuration purview.dev ignores.`, 'Delete it; the site reads the Markdown directly. Rewrite any Material-only markup it enabled as plain Markdown.'); + } +} + +// --------------------------------------------------------------------------- +// Documentation +// --------------------------------------------------------------------------- + +const docsCandidates = docsOverride ? [docsOverride] : ['docs/wiki', 'docs']; +const docsRootRelative = docsCandidates.find((candidate) => { + const absolute = join(root, candidate); + return existsSync(absolute) && statSync(absolute).isDirectory() && walk(absolute, (path) => path.endsWith('.md')).length > 0; +}); + +if (docsOverride && !docsRootRelative) { + report('CAT-001', 'error', docsOverride, undefined, `The docs folder "${docsOverride}" has no Markdown${catalogue ? " (the catalogue's docs.path)" : ''}; the site publishes nothing.`, 'Create the folder, or correct docs.path.'); +} + +/** Every Markdown file the checks read: the docs tree and the README. */ +const markdownFiles = []; +let pageSlugs = new Set(); + +if (!docsRootRelative) { + const readme = readmePath ? read(readmePath) : ''; + const sections = (readme.match(/^## /gm) ?? []).length; + const lines = readme.split('\n').length; + if (sections >= 8 || lines >= 300) { + report('DOCS-001', 'warning', 'README.md', undefined, `No docs folder, and the README has ${sections} sections over ${lines} lines — it is a documentation tree in one file.`, 'Move the reference material into docs/wiki/ topic folders (see purview-site-docs) and keep the README as the front door.'); + } else { + report('DOCS-001', 'info', undefined, undefined, 'No docs folder; purview.dev would publish README.md as the only page (docs.source: readme).', 'Fine for a small project. Create docs/wiki/ once the README outgrows one page.'); + } + if (readmePath) markdownFiles.push(readmePath); +} else { + const docsRoot = join(root, docsRootRelative); + const docFiles = walk(docsRoot, (path) => path.endsWith('.md')); + const relativeToDocs = (absolute) => toPosix(relative(docsRoot, absolute)); + const pages = docFiles.filter((path) => !/^_(Sidebar|Footer)\.md$/i.test(basename(path))); + markdownFiles.push(...pages); + if (readmePath && !pages.includes(readmePath)) markdownFiles.push(readmePath); + + // Root page: the catalogue's rootPage when it names one, else the conventional index. + const rootFiles = pages.filter((path) => dirname(path) === docsRoot); + const configuredRoot = catalogue?.rootPage + ? pages.find((path) => relativeToDocs(path).toLowerCase() === catalogue.rootPage.toLowerCase()) + : undefined; + if (catalogue?.rootPage && !configuredRoot) { + report('CAT-002', 'error', `${docsRootRelative}/${catalogue.rootPage}`, undefined, `The catalogue's docs.rootPage "${catalogue.rootPage}" does not exist; the site's sync fails.`, 'Restore the page, or ask the site maintainers to change docs.rootPage in the same release.'); + } + const index = configuredRoot ?? rootFiles.find((path) => isIndexName(basename(path))); + if (!index) { + report('DOCS-002', 'warning', docsRootRelative, undefined, 'No Home.md, index.md or README.md at the docs root.', 'Add Home.md as the hub, or have the catalogue entry name docs.rootPage.'); + } + + // Shape: flat corpus, depth. + const folders = new Set(pages.map((path) => dirname(relativeToDocs(path))).filter((folder) => folder !== '.')); + if (folders.size === 0 && pages.length >= 7) { + report('DOCS-003', 'warning', docsRootRelative, undefined, `${pages.length} pages in one flat folder.`, 'Group them into lower-case topic folders (guide/, configuration/, reference/); keep only the hub, getting-started and catalogue deep-linked pages at the root; add each move to _redirects.yml.'); + } else if (rootFiles.length >= 10 && folders.size > 0) { + report('DOCS-003', 'info', docsRootRelative, undefined, `${rootFiles.length} pages are still at the docs root beside ${folders.size} folder(s).`, 'Move pages into their topic folder unless the catalogue deep-links them (useCases[].docsPage).'); + } + for (const folder of folders) { + if (folder.split('/').length > 2) { + report('DOCS-004', 'info', `${docsRootRelative}/${folder}`, undefined, 'Folders nested more than two levels deep.', 'One level of topic folders is the default; nest only for a sub-area with several pages of its own.'); + } + } + + // Names and slugs. + const bySlug = new Map(); + for (const path of pages) { + const relativePath = relativeToDocs(path); + const slug = path === index ? 'index' : docSlug(relativePath); + const segments = slug.split('/'); + if (segments.some((segment) => !SAFE_SEGMENT.test(segment))) { + report('DOCS-005', 'error', repoPath(path), undefined, `"${relativePath}" reduces to the unpublishable slug "${slug}"; the site's sync fails on it.`, 'Rename every folder and the file with letters, digits and dashes.'); + } else { + const unsafe = relativePath.replace(/\.md$/i, '').split('/').filter((segment) => !/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/.test(segment)); + if (unsafe.length > 0) { + report('DOCS-005', 'warning', repoPath(path), undefined, `"${unsafe.join('/')}" is rewritten to publish (${slug}).`, 'Rename with letters, digits and single dashes so the file name and the URL agree.'); + } + } + if (slug === 'prerelease' || slug.startsWith('prerelease/')) { + report('DOCS-005', 'error', repoPath(path), undefined, '"prerelease" is reserved for the prerelease documentation channel.', 'Rename the page or folder.'); + } + const existing = bySlug.get(slug); + if (existing) { + report('DOCS-006', 'error', repoPath(path), undefined, `Publishes at the same URL as ${repoPath(existing)} (${slug}).`, 'Rename one of them.'); + } else { + bySlug.set(slug, path); + } + } + pageSlugs = new Set(bySlug.keys()); + + // Catalogue deep links: a use case's docsPage that is not a page fails the site's catalogue check. + for (const docsPage of catalogue?.docsPages ?? []) { + if (!pageSlugs.has(docsPage)) { + report('CAT-003', 'error', docsRootRelative, undefined, `The catalogue deep-links "${docsPage}" (useCases[].docsPage), which is not a page here; the site's build fails.`, 'Keep that page at the docs root under its name, or ask the site maintainers to update the use case in the same release.'); + } + } + if (catalogue && Object.keys(catalogue.redirects).length > 0) { + report('CAT-004', 'info', `${docsRootRelative}/_redirects.yml`, undefined, `The catalogue entry carries ${Object.keys(catalogue.redirects).length} docs.redirects pair(s).`, 'Move them into _redirects.yml here, then ask the site maintainers to drop them from the entry.'); + } + + // Sidebar. + const sidebar = docFiles.find((path) => /^_Sidebar\.md$/i.test(basename(path))); + if (!sidebar && pages.length > 3) { + report('DOCS-011', 'info', docsRootRelative, undefined, 'No _Sidebar.md; pages are ordered alphabetically on the site.', 'Add _Sidebar.md listing every page once, grouped by section, in reading order.'); + } else if (sidebar) { + const listed = new Set(); + const text = read(sidebar); + for (const match of text.matchAll(/\]\(([^)\s#]+\.md)(?:#[^)]*)?\)/g)) { + const target = posix.normalize(match[1]); + listed.add(target.toLowerCase()); + if (!existsSync(join(docsRoot, target))) { + report('DOCS-011', 'warning', repoPath(sidebar), undefined, `Lists ${match[1]}, which does not exist.`, 'Fix or remove the entry.'); + } + } + for (const path of pages) { + const relativePath = relativeToDocs(path); + if (path !== index && !listed.has(relativePath.toLowerCase())) { + report('DOCS-011', 'info', repoPath(path), undefined, 'Not listed in _Sidebar.md, so it sorts last on the site.', 'Add it to _Sidebar.md under its section.'); + } + } + } + + // Redirects. + const redirectsFile = join(docsRoot, '_redirects.yml'); + const redirected = new Set(); + if (existsSync(redirectsFile)) { + const { pairs, errors } = parseRedirects(read(redirectsFile)); + for (const error of errors) { + report('RED-001', 'error', repoPath(redirectsFile), error.line, `${error.message}; the site's sync fails on an unreadable redirects file.`, 'Use one `previous: current` pair per line under `redirects:`.'); + } + for (const pair of pairs) { + const from = redirectSlug(pair.from); + const to = redirectSlug(pair.to); + redirected.add(from); + if (from.split('/').some((segment) => !SAFE_SEGMENT.test(segment)) || to.split('/').some((segment) => !SAFE_SEGMENT.test(segment))) { + report('RED-001', 'error', repoPath(redirectsFile), pair.line, `"${pair.from}: ${pair.to}" does not reduce to publishable slugs.`, 'Use slugs or Markdown paths from the docs root.'); + } else if (from === to) { + report('RED-001', 'error', repoPath(redirectsFile), pair.line, `"${pair.from}" redirects to itself.`, 'Remove the line.'); + } else if (!pageSlugs.has(to)) { + report('RED-002', 'warning', repoPath(redirectsFile), pair.line, `"${pair.from}" points at "${to}", which is not a page; no redirect is written.`, 'Point it at the page\'s current path.'); + } else if (pageSlugs.has(from)) { + report('RED-003', 'info', repoPath(redirectsFile), pair.line, `"${from}" is still a page, so this redirect stays inactive.`, 'Expected only while the move is unreleased; otherwise remove the old page.'); + } + } + const sources = new Set(pairs.map((pair) => redirectSlug(pair.from))); + for (const pair of pairs) { + if (sources.has(redirectSlug(pair.to))) { + report('RED-005', 'warning', repoPath(redirectsFile), pair.line, `"${pair.from}" points at "${pair.to}", which itself redirects.`, 'Point every old slug at the final page.'); + } + } + } + + // Moves in git history that left no redirect (the catalogue's docs.redirects counts, when given). + for (const from of Object.keys(catalogue?.redirects ?? {})) { + redirected.add(redirectSlug(from)); + } + try { + const log = execFileSync( + 'git', + ['-C', root, 'log', '--max-count=300', '--find-renames', '--diff-filter=RD', '--name-status', '--format=', '--', docsRootRelative], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, + ); + const missing = new Map(); + const fromDocs = (path) => path.slice(docsRootRelative.length + 1); + for (const line of log.split('\n')) { + const [status, oldPath, newPath] = line.split('\t'); + if (!status || !oldPath || !oldPath.endsWith('.md') || !oldPath.startsWith(`${docsRootRelative}/`)) continue; + if (/^_/.test(basename(oldPath)) || isIndexName(basename(oldPath))) continue; + const oldSlug = docSlug(fromDocs(oldPath)); + if (pageSlugs.has(oldSlug) || redirected.has(oldSlug) || missing.has(oldSlug)) continue; + const newSlug = + status.startsWith('R') && newPath?.startsWith(`${docsRootRelative}/`) ? docSlug(fromDocs(newPath)) : undefined; + missing.set(oldSlug, newSlug && pageSlugs.has(newSlug) ? newSlug : undefined); + } + for (const [slug, current] of missing) { + report( + 'RED-004', + 'warning', + `${docsRootRelative}/_redirects.yml`, + undefined, + current + ? `"${slug}" was renamed to "${current}" in git history and has no redirect.` + : `"${slug}" was moved or deleted in git history and has no redirect.`, + current + ? `Add \`${slug}: ${current}\` to _redirects.yml (unless it was never published).` + : `If it was published, add \`${slug}: \` to _redirects.yml. Pass --catalogue to count the site's docs.redirects.`, + ); + } + } catch { + // Not a git checkout, or git is unavailable: the history check is best-effort. + } +} + +// --------------------------------------------------------------------------- +// Per-page checks (docs pages and the README) +// --------------------------------------------------------------------------- + +for (const file of markdownFiles) { + const page = repoPath(file); + const scan = scanFences(read(file)); + + const titles = []; + scan.prose.forEach((line, index) => { + if (/^#\s+\S/.test(line)) titles.push(index + 1); + }); + if (titles.length === 0) { + report('DOCS-007', 'warning', page, 1, 'No `# Title` heading; the site falls back to the file name.', 'Open the page with exactly one `# Title`.'); + } else if (titles.length > 1) { + report('DOCS-007', 'warning', page, titles[1], `${titles.length} \`#\` headings; the site keeps the first as the title and demotes the rest.`, 'Use one `#` title and `##` for sections.'); + } + + scan.prose.forEach((rawLine, index) => { + const line = stripInlineCode(rawLine); + for (const match of line.matchAll(/(!?)\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) { + const target = match[2]; + if (/^(?:[a-z][a-z0-9+.-]*:|#|\/\/|<)/i.test(target)) continue; + const path = decodeURI(target.split('#')[0].split('?')[0]); + if (!path) continue; + const absolute = path.startsWith('/') ? join(root, path) : join(dirname(file), path); + if (!existsSync(absolute)) { + const isImage = match[1] === '!'; + report(isImage ? 'DOCS-009' : 'DOCS-008', 'error', page, index + 1, `${isImage ? 'Image' : 'Link'} target ${target} does not exist.`, 'Fix the path — it resolves from the folder this page is in.'); + } + } + }); + + for (const fence of scan.fences) { + const language = fence.info.split(/\s+/)[0]; + if (fence.unclosed) { + report('DOCS-013', 'error', page, fence.start + 1, 'A code fence is never closed; everything after it renders as code.', `Close it with ${fence.run}.`); + } + if (!language) { + report('DOCS-010', 'info', page, fence.start + 1, 'A code fence has no language.', 'Label it (`csharp`, `json`, `shell`, `text`).'); + } + const kind = classifyTextFence(fence); + if (kind === 'diagram') { + report('DIAG-001', 'warning', page, fence.start + 1, 'An ASCII-art diagram.', 'Replace it with a LikeC4 model plus a Mermaid fence in a purview:likec4 block (purview-site-docs, references/diagrams.md). Move any legend into a table.'); + } else if (kind === 'mermaid') { + report('DIAG-008', 'warning', page, fence.start + 1, 'Mermaid source in a text fence, so neither GitHub nor the site draws it.', 'Label the fence ```mermaid — both render it — and, for an architecture or flow diagram, pair it with a LikeC4 model in a purview:likec4 block.'); + } + } + + const blocks = checkDiagramBlocks(file, scan); + const insideBlock = (line) => blocks.some((block) => line > block.start && line < block.end); + for (const fence of scan.fences) { + const language = fence.info.split(/\s+/)[0]; + if (insideBlock(fence.start)) continue; + if (language === 'likec4' || language === 'c4') { + if (language === 'likec4') { + report('DIAG-003', 'warning', page, fence.start + 1, 'A bare ```likec4 fence: GitHub shows it as source code.', 'Move the model to a .c4 file and use a purview:likec4 block with a Mermaid fence for GitHub.'); + } + } else if (language === 'mermaid') { + report('DIAG-002', 'info', page, fence.start + 1, 'A Mermaid diagram with no LikeC4 pair. Both GitHub and the site render it.', 'Optional: for an architecture or flow diagram, add a LikeC4 model and wrap this fence in a purview:likec4 block.'); + } + } +} + +// --------------------------------------------------------------------------- +// Agent skills +// --------------------------------------------------------------------------- + +const skillsRoot = join(root, 'skills'); +if (existsSync(skillsRoot) && statSync(skillsRoot).isDirectory()) { + for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const folder = `skills/${entry.name}`; + const skillFile = join(skillsRoot, entry.name, 'SKILL.md'); + if (!existsSync(skillFile)) { + report('SKILL-001', 'error', folder, undefined, 'A skills folder with no SKILL.md.', 'Add SKILL.md, or move the folder out of skills/.'); + continue; + } + const text = read(skillFile); + const frontMatter = /^---\n([\s\S]*?)\n---/.exec(text)?.[1]; + if (!frontMatter) { + report('SKILL-002', 'error', `${folder}/SKILL.md`, 1, 'No YAML front matter.', 'Start the file with `---`, `name:` and `description:`, then `---`.'); + continue; + } + const name = /^name:\s*["']?([^"'\n]+?)["']?\s*$/m.exec(frontMatter)?.[1]; + const description = /^description:\s*(.*)$/m.exec(frontMatter)?.[1]; + if (!name) { + report('SKILL-002', 'error', `${folder}/SKILL.md`, 1, 'Front matter has no `name`.', `Add \`name: ${entry.name}\`.`); + } else if (name !== entry.name) { + report('SKILL-003', 'error', `${folder}/SKILL.md`, 1, `name "${name}" does not match the folder "${entry.name}".`, 'Make them equal; the CLI and the site key the skill on the folder.'); + } + if (name && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) { + report('SKILL-004', 'warning', `${folder}/SKILL.md`, 1, `name "${name}" is not a lower-case slug.`, 'Use lower-case letters, digits and dashes.'); + } + if (description === undefined || description.trim() === '') { + report('SKILL-002', 'error', `${folder}/SKILL.md`, 1, 'Front matter has no `description`.', 'Say what the skill does and when to use it, in one paragraph.'); + } + const scan = scanFences(text); + scan.prose.forEach((line, index) => { + for (const match of stripInlineCode(line).matchAll(/\]\(([^)\s#:]+)(?:#[^)]*)?\)/g)) { + if (!existsSync(join(skillsRoot, entry.name, match[1]))) { + report('SKILL-005', 'error', `${folder}/SKILL.md`, index + 1, `Links ${match[1]}, which is not in the skill folder.`, 'Ship the file beside SKILL.md, or fix the link.'); + } + } + }); + } +} + +// --------------------------------------------------------------------------- +// Report +// --------------------------------------------------------------------------- + +const order = { error: 0, warning: 1, info: 2 }; +findings.sort((a, b) => order[a.severity] - order[b.severity] || a.id.localeCompare(b.id) || String(a.file).localeCompare(String(b.file))); +const counts = { error: 0, warning: 0, info: 0 }; +for (const finding of findings) counts[finding.severity] += 1; + +if (json) { + console.log(JSON.stringify({ root, docsRoot: docsRootRelative ?? null, pages: pageSlugs.size, counts, findings }, null, 2)); +} else { + const lines = [ + `# purview.dev assessment: ${basename(root)}`, + '', + `Docs root: ${docsRootRelative ?? '(none — README only)'} · pages: ${pageSlugs.size || (readmePath ? 1 : 0)} · ` + + `${counts.error} error(s), ${counts.warning} warning(s), ${counts.info} note(s)`, + '', + ]; + if (findings.length === 0) { + lines.push('No findings.'); + } else { + lines.push('| Severity | Rule | Where | Finding | Fix |', '| -- | -- | -- | -- | -- |'); + const cell = (value) => String(value).replace(/\|/g, '\\|').replace(/\n/g, ' '); + for (const finding of findings) { + const where = finding.file ? `${finding.file}${finding.line ? `:${finding.line}` : ''}` : '—'; + lines.push(`| ${finding.severity} | ${finding.id} | ${cell(where)} | ${cell(finding.message)} | ${cell(finding.fix)} |`); + } + } + console.log(lines.join('\n')); +} + +process.exit(counts.error > 0 ? 1 : 0); diff --git a/.agents/skills/purview-site-docs/SKILL.md b/.agents/skills/purview-site-docs/SKILL.md new file mode 100644 index 00000000..9e916d34 --- /dev/null +++ b/.agents/skills/purview-site-docs/SKILL.md @@ -0,0 +1,117 @@ +--- +name: purview-site-docs +description: Write and reorganise a Purview repository's documentation so purview.dev publishes it cleanly - a hierarchical docs/wiki tree (topic folders, not one flat folder), one H1 per page, relative links that resolve from the page they are in, GitHub alerts, labelled fences, LikeC4 diagrams paired with a Mermaid fence so GitHub and the site each render the one they can, and a _redirects.yml in the docs root that keeps old URLs working when pages move. Use when adding, moving, renaming or splitting documentation pages, when drawing a diagram (never ASCII art), when a docs folder has grown flat, or when asked how a page will look on purview.dev. +license: MIT +metadata: + site: https://purview.dev + repository: purview-dev/purview-dev +--- + +# Documentation that publishes cleanly on purview.dev + +purview.dev does not host your documentation; it **reads it out of your repository** at build time +and publishes it at `https://purview.dev/docs//`. The same Markdown is also read on +GitHub. Every rule here keeps one file correct in both places, with no site configuration in your +repository. + +The site's catalogue entry for your project (in `purview-dev/purview-dev`, +`src/src/data/projects/.yml`) decides **which** files are read — `docs.source`, `docs.path`, +`docs.rootPage`, `docs.exclude`. This repository decides **what they say and where they sit**. + +## The rules + +| # | Rule | Why | +| - | - | - | +| 1 | Docs live in `docs/wiki/` (or `docs/`), as plain `.md` files. No `mkdocs.yml`, theme or plugin list. | The site renders; the repository only writes. A site file is ignored and drifts. | +| 2 | **Hierarchical, not flat**: one lower-case topic folder per area (`guide/`, `configuration/`, `reference/`). Only the landing page, `Home.md`, `_Sidebar.md` and pages the catalogue deep-links stay at the root. | A folder is a sidebar group and a URL segment: `guide/Getting-Started.md` → `/docs//guide/getting-started/`. Twenty files in one folder is a list nobody scans. | +| 3 | File and folder names: letters, digits, single dashes (`Custom-Rules.md`). | The path *is* the URL, lower-cased; anything else is rewritten, and a name that reduces to nothing fails the sync. | +| 4 | Exactly one `# Title` per page, first; then one paragraph saying what the page answers. | The H1 is the page title and sidebar label; the first paragraph is the meta description (160 chars). | +| 5 | Relative links, resolved from the file they are in (`../reference/Options.md`, `Sibling.md#anchor`). | The site rewrites them to its own URLs; a link outside the docs folder becomes a GitHub link. | +| 6 | Relative images (`./images/flow.png`); badges may stay absolute. | Relative images are served from GitHub raw at the channel's ref. | +| 7 | Every fence declares a language (`csharp`, `json`, `shell`, `text`). | An unlabelled fence loses highlighting on both sides. | +| 8 | Callouts use GitHub alerts: `> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`, `> [!WARNING]`, `> [!CAUTION]`. | They become Starlight asides on the site and render natively on GitHub. | +| 9 | **Diagrams are never ASCII art.** A diagram is a LikeC4 model paired with a Mermaid fence (below). | ASCII art is unreadable on a phone, unsearchable, and drifts silently. | +| 10 | Moving or renaming a page adds a line to `_redirects.yml` in the same commit. | The old URL is in bookmarks, search results and other repositories. | +| 11 | `_Sidebar.md` lists every page once, grouped by section, in reading order. | The site orders pages by it; a page missing from it has no place in the navigation. | + +Full layout rules, a worked tree and what the site rewrites: [references/layout.md](references/layout.md). + +## Hierarchy: the default shape + +```text +docs/wiki/ + _Sidebar.md navigation (not published) + _redirects.yml moves (not published) + Home.md hub / landing page (docs.rootPage) + Getting-Started.md root only because the catalogue deep-links it + guide/ + README.md the folder's own page: /docs//guide/ + Installation.md + First-Project.md + configuration/ + Options.md + Environment-Variables.md + diagrams/ + request-flow.c4 LikeC4 models (not published as pages) +``` + +One level of folders is the default; go deeper only when a folder holds a sub-area with several pages +of its own. A nested `README.md` (or `index.md`) is that folder's landing page. A page the site's +catalogue deep-links from a use case (`useCases[].docsPage`) is a single URL segment, so it must stay +at the root until the catalogue entry moves with it — check the entry before moving one. + +## Diagrams: LikeC4 on the site, Mermaid on GitHub + +Draw each diagram once as a LikeC4 model in a `.c4` file, and once as Mermaid for GitHub. Wrap the +Mermaid fence in the `purview:likec4` markers: + +````markdown + +```mermaid +flowchart LR + client[Client] --> api[API] --> store[(Store)] +``` + +[LikeC4 model](./diagrams/request-flow.c4) + +```` + +- **On GitHub** the comments are invisible: the reader sees the Mermaid diagram and a link to the model. +- **On purview.dev** the sync replaces the whole block — markers, Mermaid and link — with the LikeC4 + view, rendered interactively. The Mermaid is never shown there. +- If the model cannot be read or does not declare the view, the site keeps the Mermaid and the sync + logs a warning, so a mistake costs fidelity, never the page. + +Marker options, the model rules (one self-contained file per diagram), and a full worked conversion of an +ASCII flow: [references/diagrams.md](references/diagrams.md). + +## Moving pages: `_redirects.yml` + +```yaml +# docs/wiki/_redirects.yml — previous: current (slugs, or paths from the docs root) +redirects: + getting-started: guide/getting-started + Custom-Rules.md: schemas/Custom-Rules.md +``` + +The site reads the file at each documentation channel's own ref and writes a redirect page for every +pair whose destination exists and whose source is no longer a page. A pair whose destination is missing, +or whose source still exists, is reported and skipped; an invalid file fails the site's sync with the +file name, so fix it rather than delete it. Never remove a line once released — the old URL lives in +someone's bookmark. Details: [references/redirects.md](references/redirects.md). + +## Moving a page — the checklist + +1. `git mv` the file, so history follows it. +2. Fix every inbound link: other pages (relative to *their* folder), `_Sidebar.md`, `Home.md`, the + READMEs, `AGENTS.md`, skills, and tests that name the path. +3. Add `old-slug: new/slug` to `_redirects.yml`. +4. If the page is a catalogue deep link (`docsPage`), it stays at the root, or ask the site maintainers + to move the deep link; do not edit `purview-dev/purview-dev` unasked. +5. Run the assessment (`purview-site-assessment`) and fix what it reports. + +## Related skills + +- `purview-site-integration` — everything else the site reads from a repository (topics, releases, + packages, skills) and how a project joins the catalogue. +- `purview-site-assessment` — a script and a procedure that check a repository against these rules. diff --git a/.agents/skills/purview-site-docs/references/diagrams.md b/.agents/skills/purview-site-docs/references/diagrams.md new file mode 100644 index 00000000..37103645 --- /dev/null +++ b/.agents/skills/purview-site-docs/references/diagrams.md @@ -0,0 +1,174 @@ +# Diagrams: a LikeC4 model paired with a Mermaid fence + +GitHub renders Mermaid and shows LikeC4 as source. purview.dev renders LikeC4 (interactive, with the +model behind it) and Mermaid. A diagram pair gives each reader the best diagram their renderer can show, +from one page. + +## The block + +````markdown + +```mermaid +flowchart LR + resume[pm-resume] --> next[pm-next] +``` + +[LikeC4 model](./diagrams/the-loop.c4) + +```` + +| Part | Rule | +| -- | -- | +| Opening marker | `` alone on its line | +| `file` | **required.** The `.c4` model, relative to the page (or `/`-rooted at the repository root). It may live anywhere in the repository, including outside the docs folder | +| `view` | the view to render; defaults to the first view the model declares. Name it explicitly | +| `title`, `description` | the heading and sub-heading of the diagram on the site | +| `height` | pixels, default `420` | +| `collapsible`, `defaultOpen` | `true`/`false`; the site's diagram shell is collapsible and open by default | +| Between the markers | what GitHub shows: one ```` ```mermaid ```` fence, then optionally a link to the model | +| Closing marker | `` alone on its line | + +Markers inside a code fence are ignored (that is how this page shows them). + +## What happens + +| Reader | Sees | +| -- | -- | +| GitHub | the Mermaid diagram and the "LikeC4 model" link; the comments are invisible | +| purview.dev | the LikeC4 view only. The sync swaps the whole block for a ```` ```likec4 ```` fence holding the model file's text | +| purview.dev, model unreadable or view undeclared | the Mermaid diagram, and a warning in the site's sync log naming the page, line and reason | + +## The model file + +- **One self-contained file per diagram**: its own `specification`, `model` and `views`. The site + compiles the file on its own, so it cannot see other `.c4` files — no shared specification across + files, no `import`. +- Keep models beside the docs (`docs/wiki/diagrams/.c4`). A `.c4` file is never published as a + page. +- Element and view ids: letters, digits, underscores, starting with a letter (`theLoop`, `apiGateway`). +- Validate before committing: `npx likec4 validate docs/wiki/diagrams` (or `bunx likec4 validate …`). +- Prefer `autoLayout LeftRight` for flows and `TopBottom` for hierarchies; `include *` for a small model. + +## The Mermaid rendering + +- It is the GitHub reader's diagram, so keep it equivalent: the same nodes and edges, the same labels. + It does not need the styling or descriptions the model carries. +- `flowchart LR`/`TB` for flows; `sequenceDiagram` when the LikeC4 view is a `dynamic view`. +- When the model changes, the Mermaid changes in the same commit — review them side by side. + +## Never ASCII art + +A fenced ```` ```text ```` block of boxes and arrows is a diagram nobody can read on a phone, search, +or keep in step. Convert it. A legend that sits under ASCII art ("pm-park: any time, capture only") +becomes a table or a list after the diagram, not part of it. + +## Worked conversion + +The PM skills README describes its loop as ASCII art: + +```text +pm-resume -> pm-next -> [design -> plan -> execute -> finish] + ^ | | | + | pm-checkpoint (stage updates) pm-checkpoint (done, on the branch) + | | + +-----------------------------------------+ + | + push branch -> one PR -> CI -> merge +``` + +**1. The model** — `docs/diagrams/the-loop.c4`: + +```likec4 +specification { + element skill { + style { + shape rectangle + } + } + element stage { + style { + shape rectangle + color secondary + } + } + element delivery { + style { + shape rectangle + color muted + } + } +} + +model { + resume = skill 'pm-resume' { + description 'Reads the planning files and recommends one action' + } + next = skill 'pm-next' { + description 'Claims the first item that is still the right one' + } + design = stage 'design' + plan = stage 'plan' + execute = stage 'execute' + finish = stage 'finish' + checkpoint = skill 'pm-checkpoint' { + description 'Writes where work stopped, so a compacted session can resume' + } + push = delivery 'push branch' + pr = delivery 'one PR' + ci = delivery 'CI' + merge = delivery 'merge' + + resume -> next + next -> design 'claims an item' + design -> plan + plan -> execute + execute -> finish + design -> checkpoint 'stage updates' + plan -> checkpoint 'stage updates' + execute -> checkpoint 'stage updates' + finish -> checkpoint 'done, on the branch' + checkpoint -> next 'next item' + checkpoint -> push + push -> pr + pr -> ci + ci -> merge +} + +views { + view theLoop { + title 'The loop' + include * + autoLayout LeftRight + } +} +``` + +**2. The README section**: + +````markdown +## The loop + + +```mermaid +flowchart LR + resume[pm-resume] --> next[pm-next] + next -- claims an item --> design + design --> plan --> execute --> finish + design & plan & execute -. stage updates .-> checkpoint[pm-checkpoint] + finish -- done, on the branch --> checkpoint + checkpoint -- next item --> next + checkpoint --> push[push branch] --> pr[one PR] --> ci[CI] --> merge +``` + +[LikeC4 model](./docs/diagrams/the-loop.c4) + + +| Skill | When it runs | +| -- | -- | +| `pm-park` | any time, capture only | +| `pm-review` | weekly | +| `pm-risk` | capture a risk any time; manage the register when no workflow is active | +```` + +The file path is relative to the page, so from the root `README.md` it is `./docs/diagrams/the-loop.c4`; +from `docs/wiki/guide/Loop.md` it would be `../../diagrams/the-loop.c4`. diff --git a/.agents/skills/purview-site-docs/references/layout.md b/.agents/skills/purview-site-docs/references/layout.md new file mode 100644 index 00000000..ba8a033b --- /dev/null +++ b/.agents/skills/purview-site-docs/references/layout.md @@ -0,0 +1,92 @@ +# Layout and what the site does to a page + +## Which files are read + +The catalogue entry's `docs.source` decides: + +| `docs.source` | Read | Use it when | +| -- | -- | -- | +| `github-path` | every `/**/*.md` at the channel's ref (default branch, or a release tag for a `live` channel) | **the default.** Any repository with more than one page. Supports folders, channels and redirects | +| `wiki` | the top-level `.md` files of the GitHub wiki (`.wiki.git`) | only for an existing GitHub wiki; it has no history the site can pin, and no folders | +| `readme` | `README.md` alone, as one page | a project small enough to be one page. Grows into `github-path` — a README with more than about eight `##` sections or a few hundred lines is a docs tree waiting to happen | + +Files that are never pages: `_Sidebar.md`, `_Footer.md`, `_redirects.yml`, `.c4` models, images, and +anything the entry lists in `docs.exclude` (matched on the base file name, `*` wildcards). + +## The landing page + +1. A conventional index at the docs root wins: `Home.md`, `index.md` or `README.md` (case-insensitive). +2. The entry's `docs.rootPage` may name another page only when the conventional one is excluded. +3. No index and no `rootPage` fails the sync: *"docs: no root page exists"*. + +Prefer `Home.md` as the hub (what the project is, then a table of sections linking every page) and a +`Getting-Started.md` beside it. + +## URL of every page + +The path from the docs root, each segment lower-cased with anything other than letters, digits and +dashes collapsed to `-`, and the `.md` dropped: + +| Source | Published at | +| -- | -- | +| `docs/wiki/Home.md` (root page) | `/docs//` | +| `docs/wiki/Getting-Started.md` | `/docs//getting-started/` | +| `docs/wiki/guide/README.md` | `/docs//guide/` | +| `docs/wiki/guide/First-Project.md` | `/docs//guide/first-project/` | +| `docs/wiki/Q&A/Why Not.md` | `/docs//q-a/why-not/` (works, but rename it) | +| `docs/wiki/___/Page.md` | **sync fails**: the folder reduces to nothing | + +Two files that reduce to the same URL collide; keep names distinct after lower-casing. A page may not +be named `prerelease` (that segment is reserved for the prerelease documentation channel). + +## Sidebar and order + +- Folders become collapsible sidebar groups labelled from the folder name (`getting-started` → + "Getting Started"), placed where their first page appears. +- Order: the entry's `docs.order` (explicit slugs) → the link order in `_Sidebar.md` → alphabetical. +- `_Sidebar.md` links pages by their path from the docs root: + +```markdown +- [Home](Home.md) +- **Guide** + - [Installation](guide/Installation.md) + - [First project](guide/First-Project.md) +- **Configuration** + - [Options](configuration/Options.md) +``` + +## What the site rewrites + +| In the source | On the site | +| -- | -- | +| `[Options](../configuration/Options.md#timeouts)` | a link to `/docs//configuration/options/#timeouts`; an anchor that is not a heading on the target page is dropped | +| `[Code](../../src/Thing.cs)` (outside the docs folder) | `https://github.com//blob//src/Thing.cs` | +| `![Flow](./images/flow.png)` | `https://github.com//raw///images/flow.png` | +| `![badge](https://img.shields.io/…)` | unchanged | +| `> [!WARNING]` blockquote | a Starlight caution aside | +| the first `# Title` | the page header (title, sidebar label); removed from the body so it shows once | +| a second `# Heading` | demoted to `##` — one title per page | +| a `purview:likec4` block | the LikeC4 view (see `diagrams.md`) | +| a bare ```` ```mermaid ```` fence | a Mermaid diagram (both sides render it) | +| a bare ```` ```likec4 ```` fence | a LikeC4 diagram on the site — but **source code on GitHub**; use a pair | +| mkdocs/Material markup (`!!! note`, `{ .md-button }`, `{% … %}`) | stripped or converted; do not write it | + +Front matter in a source page is not needed: the site writes its own (title, description, edit link, +last-reviewed date from the last commit, the repository's topics as tags). A page not touched for 365 +days is flagged as stale on the site. + +## Documentation channels + +A project whose default branch runs ahead of its last release can publish two versions of its docs: +`live` (pinned to the latest stable tag, at `/docs//`) and `prerelease` (the default branch, at +`/docs//prerelease/`). This is declared in the catalogue entry, not in the repository. What it means +for you: a page reorganisation is visible on the prerelease channel immediately and on live only after +the release that carries it, and `_redirects.yml` is read per channel, so each channel forwards its own +old URLs at the right moment. + +## Writing style that survives both renderers + +- ATX headings (`##`), sentence or title case consistently, no trailing punctuation. +- Tables for anything enumerable. +- Wrap prose near 100–110 columns, so documentation diffs stay reviewable. +- HTML only where Markdown cannot express it; the site renders it, but GitHub sanitises it. diff --git a/.agents/skills/purview-site-docs/references/redirects.md b/.agents/skills/purview-site-docs/references/redirects.md new file mode 100644 index 00000000..76585874 --- /dev/null +++ b/.agents/skills/purview-site-docs/references/redirects.md @@ -0,0 +1,49 @@ +# `_redirects.yml` — keeping moved pages reachable + +A page's path is its URL. Moving `Getting-Started.md` into `guide/` moves +`/docs//getting-started/` to `/docs//guide/getting-started/`, and every bookmark, search result +and link from another repository to the old address would 404. `_redirects.yml` records the move in the +same commit as the move. + +## Where and what + +`/_redirects.yml` — beside `Home.md` (for a GitHub wiki, at the wiki root). It is never a page. + +```yaml +# previous: current +# Either side may be a slug (`guide/getting-started`) or a Markdown path from the +# docs root (`guide/Getting-Started.md`); both are reduced to the published slug. +redirects: + getting-started: guide/getting-started + Custom-Rules.md: schemas/Custom-Rules.md + old-landing: index # `index` is the docs root, /docs// +``` + +A bare map without the `redirects:` key is accepted too. Keep to this shape: one `previous: current` +pair per line, comments with `#`, quotes only when a name needs them. + +## How the site uses it + +1. The sync reads the file at each documentation channel's ref — so a reorganisation on the default + branch forwards the prerelease channel's URLs straight away, and the live channel's only once the + release that contains it is published. +2. For each pair it writes a small redirect page at the old address pointing to the new one + (`noindex`, not searchable, not in the sitemap). +3. A redirect is **written only when its destination is a published page and its source is not.** A + real page always wins over a redirect. +4. A pair that points nowhere, or whose source still exists, is reported in the sync log and skipped. +5. A file that is not valid YAML, is not a map, or holds a pair that cannot become a URL, **fails the + site's sync** naming the file — the moves it was meant to protect would otherwise vanish silently. + +The site's catalogue entry may also carry `docs.redirects` for the same purpose; when both name the +same old slug the catalogue entry wins. Prefer the file: the move and its redirect then live in one +commit, in the repository that made them. + +## Rules + +- Add the pair in the commit that moves the page. Never remove a released pair. +- Point at the final address. When a page moves twice, update the first pair's destination rather than + chaining `a → b → c`. +- Redirect pages only — not anchors, not folders. A folder with a `README.md` is a page (`guide`), so + it can be a destination. +- After the move, fix inbound links anyway: a redirect is for readers outside the repository. diff --git a/.agents/skills/purview-site-integration/SKILL.md b/.agents/skills/purview-site-integration/SKILL.md new file mode 100644 index 00000000..24cd40a3 --- /dev/null +++ b/.agents/skills/purview-site-integration/SKILL.md @@ -0,0 +1,89 @@ +--- +name: purview-site-integration +description: Make a Purview repository (a NuGet library, an MSBuild SDK, an npm package, or a pure agent-skills repository such as pm-skills) fully integrated with purview.dev - what the site reads from the repository and when, the GitHub metadata it shows (topics as tags, description, releases, default branch), the documentation contract, published agent skills, mirrored assets, and the catalogue entry the site maintainers write for it. Use when preparing a repository to join purview.dev, when its project page or docs look wrong or stale, when adding skills, packages or releases a reader should see, or when asked "how does this show up on purview.dev?". +license: MIT +metadata: + site: https://purview.dev + repository: purview-dev/purview-dev +--- + +# Integrating a repository with purview.dev + +purview.dev is a static site built from two inputs: + +1. **The catalogue** — one YAML file per project in `purview-dev/purview-dev` + (`src/src/data/projects/.yml`): the curated words (description, origin, when to use it, use + cases) and the pointers (which docs to read, which packages and skills to show). +2. **This repository** — read at build time through the GitHub API, raw file URLs and the package + registries. Nothing is pushed to the site; a scheduled build and every site deploy pull it. + +So integration has two halves: make this repository **readable** (this skill and +`purview-site-docs`), and give the site maintainers what they need to **write the entry** +([references/catalogue-contract.md](references/catalogue-contract.md)). Do not edit +`purview-dev/purview-dev` from this repository unless the user asks; propose the entry instead. + +## What the site reads from this repository + +| Site surface | Read from | Your responsibility | +| -- | -- | -- | +| Tag chips on cards, search, docs pages | GitHub **topics** | 6–15 lower-case, hyphenated topics (`csharp`, `source-generators`, `opentelemetry`). The first four are what a card shows. There is no other way to set tags | +| "From the repository" note on the project page | GitHub **description** | One sentence that agrees with the catalogue's `shortDescription` | +| Versions, release dates, "latest release" | GitHub **releases** + the NuGet / npm registry | Tag releases `vX.Y.Z`; prereleases `vX.Y.Z-prerelease.N`. Publish packages to the registry the entry names | +| Status checks (`stable`/`preview`/`archived`) | releases, registry versions, the archived flag | A stable registry version means `stable`; prerelease-only means `preview`; archive the repository rather than leave it idle | +| Documentation at `/docs//` | `docs/wiki/**/*.md` (or `docs/`, a wiki, or `README.md`) at the **default branch** (or a release tag) | Follow `purview-site-docs`: hierarchical folders, one H1, relative links, diagram pairs, `_redirects.yml` | +| Agent skills listed on the project page, served at `/skills///` | every `skills//SKILL.md` (discovered; the entry can opt out) and everything beside it | Front matter `name` (equal to the folder) and `description`. Skills reach users through `npx skills add purview-dev/`, never inside a package | +| Mirrored assets (JSON schemas and similar) | repository paths the entry lists under `assets:` | Keep the path stable; moving it breaks the site build until the entry follows | +| Build badge | the repository's GitHub Actions | A workflow that runs on the default branch | +| Discussions link | repository settings | Enable Discussions if you want readers to ask there | +| Star count | GitHub | — | + +The default branch must be `main` unless the entry says otherwise; the docs, the edit links and the +"last reviewed" dates all follow it. + +## The integration checklist + +1. **Metadata** — description set, 6–15 topics, the repository public, Discussions decided: + + ```shell + gh api repos/purview-dev/ --jq '{description, topics, archived, default_branch, has_discussions}' + ``` + +2. **Releases** — at least one GitHub release whose tag matches the packages' version, published to + NuGet or npm. A project with no release shows no version anywhere. +3. **Documentation** — a `docs/wiki/` tree (or a deliberate one-page `README.md`), laid out per + `purview-site-docs`. ASCII-art diagrams converted to LikeC4/Mermaid pairs. A `_redirects.yml` the + moment a page moves. +4. **Skills** — one folder per skill under `skills/`, each with a `SKILL.md` whose front matter `name` + matches the folder and whose `description` says what it does and when to use it. +5. **README** — the GitHub front door: what it is, install, a minimal example, a link into the docs. + It points into the docs rather than duplicating them (a `readme`-sourced project is the exception: + the README *is* the docs). +6. **The entry** — collect what [references/catalogue-contract.md](references/catalogue-contract.md) + asks for, and hand it to the site maintainers (an issue or pull request on + `purview-dev/purview-dev`). The site's own agents onboard projects from that information. +7. **Assess** — run the `purview-site-assessment` skill and fix every error before asking for the + entry. + +## Live examples and diagrams on the project page + +A project page can link a live example: an `.mdx` page in the site repository +(`src/src/pages/examples/.mdx`). That is authored on the site side; propose it with the entry when +the project has something visual to show. Diagrams inside your docs need nothing from the site — use +the diagram pairs from `purview-site-docs`. + +## When something looks wrong on the site + +| Symptom | Usual cause | +| -- | -- | +| No tags on the card | no GitHub topics | +| Version missing or old | no release, a tag that does not match the package version, or the registry has not indexed it yet | +| A docs page missing | not under the docs path, excluded by name, or a slug that collides with another | +| A link on a docs page goes to GitHub | it points outside the docs folder, or at a page that does not exist | +| A diagram shows Mermaid on the site | the `purview:likec4` model file or view was not found — the site's sync log says which | +| A bookmarked docs URL 404s | a page moved without a `_redirects.yml` line | +| Skills not listed | the skill is not at `skills//SKILL.md`, its front matter is missing a field, or the entry opts out with `skills: false` | + +## Related skills + +- `purview-site-docs` — how to write and organise the documentation itself. +- `purview-site-assessment` — check this repository against both skills and get a prioritised report. diff --git a/.agents/skills/purview-site-integration/references/catalogue-contract.md b/.agents/skills/purview-site-integration/references/catalogue-contract.md new file mode 100644 index 00000000..d945fa47 --- /dev/null +++ b/.agents/skills/purview-site-integration/references/catalogue-contract.md @@ -0,0 +1,93 @@ +# The catalogue entry — what to hand the site maintainers + +The entry lives in `purview-dev/purview-dev` at `src/src/data/projects/.yml` and is validated by the +site's schema, so its vocabulary is fixed. Collect the answers below from this repository and propose +them; the maintainers (or the site's onboarding agent) turn them into the entry. + +## Identity + +| Field | What to provide | +| -- | -- | +| `id` | lower-case slug, normally the repository name without `purview-` (`unions`, `build-sdk`). It is the URL: `/projects//`, `/docs//` | +| `name` | display name; must slugify to `id` when the project has docs (`Build SDK` → `build-sdk`). Not prefixed with "Purview" | +| `repository` | `purview-dev/` | +| `category` | one of `application-framework`, `validation`, `observability`, `source-generation`, `aspire`, `build-tooling`, `developer-tooling` | +| `status` | `stable` (a stable release is published), `preview` (prereleases only), `archived` | +| `experimental` | `true` when the API and packaging may change without notice | + +## The words + +Write these for a reader deciding whether to use the project. Backticks become inline code on the site. + +| Field | Shape | +| -- | -- | +| `shortDescription` | one sentence; the card text | +| `description` | 2–4 sentences: the problem and what the project does about it | +| `origin` | why it became a reusable project rather than code in one application | +| `useWhen` / `avoidWhen` | the honest fit, including when something simpler is better | +| `useCases` | at least one, each: `audience` (`developer`, `team-lead`, `architect`, `contributor`), `title`, `scenario`, optional `code` + `language`, `outcome`, optional `evidence`, optional `docsPage` | +| `acknowledgments` | upstream work the project builds on: `name`, `url`, `description` | +| `related` | ids of related Purview projects | + +A use case is concrete: a real situation, real code, a measurable outcome, and evidence that can be +checked (a test, a benchmark, a diagnostic id). `docsPage` is a **single-segment** slug of a docs page +at the docs root — so a page used as a deep link stays at the root of the tree. + +## Pointers + +| Field | What to provide | +| -- | -- | +| `docs.source` | `github-path` (preferred), `wiki`, or `readme` | +| `docs.path` | the docs folder, e.g. `docs/wiki` | +| `docs.rootPage` | the landing page, e.g. `Home.md` or `Getting-Started.md` | +| `docs.exclude` | base file names that are not pages, e.g. `_Sidebar.md` | +| `docs.channels` | only when `main` runs ahead of the last release: `live: { ref: latest-stable }`, `prerelease: { ref: default }` | +| `packages` | each package id, its `registry` (`nuget` default, or `npm`), a one-line `description`, and which is `primary` | +| `install` | for NuGet: `nuget` (a `PackageReference`), `msbuild-sdk`, or `dotnet-tool` | +| `targetFrameworks` | what the packages actually ship (`net8.0`, `net10.0`, `netstandard2.0`) — the site checks them against NuGet | +| `skills` | usually omitted: every `skills/*/SKILL.md` is discovered and published. `false` opts out; a list of `{ name, path?, description? }` overrides discovery | +| `assets` | `{ path, output, description? }` for files the site should serve, e.g. a JSON schema at `/schemas/.json` | + +Redirects for moved pages belong in this repository's `_redirects.yml`, not in the entry. + +## A template to fill in + +```yaml +id: +name: +shortDescription: +description: >- + <2–4 sentences> +origin: >- + +useWhen: >- + +avoidWhen: >- + +repository: purview-dev/ +category: +status: +docs: + source: github-path + path: docs/wiki + rootPage: Home.md + exclude: + - _Sidebar.md +packages: + - id: + description: + primary: true +useCases: + - audience: developer + title: + scenario: >- + + code: | + + language: csharp + outcome: >- + + evidence: >- + + docsPage: +``` diff --git a/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md index 68e79d3c..b256caad 100644 --- a/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md @@ -276,7 +276,10 @@ A generator project should normally include: ### Types and members -- Types: `Class`, `Struct`, `RecordClass`, `RecordStruct`, `Interface`, `Enum`, `Delegate` +- Types: `Class`, `Struct`, `RecordClass`, `RecordStruct`, `Union`, `Interface`, `Enum`, `Delegate` +- A C# 15 union declares its case types through `TypeDeclarationOptions.UnionCaseTypes` (for example + `writer.Union("Pet", [cat, dog])`); a closed hierarchy sets `IsClosed` on a class or record class + to emit `closed`, which replaces the default `sealed`. - Members: `Method`, `MethodScope`, `Property`, `Field`, `Constructor` - Attributes: `AttributeDeclarationOptions`, `AttributeArgumentOptions` - Type syntax: `TypeReference` (nullable/generic/array/pointer-safe composition) diff --git a/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md b/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md index b80ebe74..d5b43b4b 100644 --- a/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md @@ -71,6 +71,10 @@ Opt out with `PurviewSourceGeneratorFrameworkAnalyzerValidation=false` (`PRSGD00 ## CodeWriter rules +The rules that recommend a structured API are **warnings**, not IDE-only suggestions: `PSGFR16`, +`PSGFR18`–`PSGFR23`, and `PSGFR34`. A build that treats warnings as errors therefore fails until the +call is rewritten or explicitly suppressed. + | Rule | Summary | | --- | --- | | `PSGFR16` | Prefer the nullable-context `Nullable()`/`MakeNullable()` overload, so annotations honour the target compilation. | @@ -138,6 +142,9 @@ Most rules above have a fix in `Purview.SourceGeneratorFramework.CodeFixers`: | `PreferNullableContextOverloadCodeFixProvider` (project-wide Fix All) | `PSGFR16` | | `PipelineModelReferenceEqualityCollectionCodeFixProvider` | `PSGFR15` | | `PreferStructuredCodeWriterIfBlockCodeFixProvider` | `PSGFR23` | +| `PreferStructuredCodeWriterStatementCodeFixProvider` (single-line statements) | `PSGFR19` | +| `PreferMinimalCodeWriterOverloadCodeFixProvider` | `PSGFR20` | +| `PreferPragmaDisableCodeFixProvider` (`disable` only) | `PSGFR22` | | `CodeWriterToStringCodeFixProvider` | `PSGFR29` | | `PreferInlineCodeForFrameworkCrefCodeFixProvider` | `PSGFR40` | | `MakeComponentSurfaceNonPublicCodeFixProvider` | `PSGFR41` | diff --git a/.agents/skills/purview-sourcegenerator-testing/SKILL.md b/.agents/skills/purview-sourcegenerator-testing/SKILL.md index 5590a896..af56ece4 100644 --- a/.agents/skills/purview-sourcegenerator-testing/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-testing/SKILL.md @@ -89,7 +89,7 @@ result.Generated().GetMethod("Format").HasParameters(TypeReference.Create.yml`) and validated by the manifest +schema. The governing decision is `docs/decisions/0002-concrete-use-cases.md`. + +## Shape + +```yaml + useCases: + - audience: developer # developer | team-lead | architect | contributor + title: Tracing, logging, and metrics from a single interface + scenario: >- # the situation the reader is in + You want OpenTelemetry activities, structured logs, and metrics around an order service, + but you do not want to hand-write the ActivitySource, ILogger, and Meter wiring. + code: | # short snippet; `language` becomes required + [ActivitySource] + [Logger] + [Meter] + public interface IOrderServiceTelemetry + { + [Activity] + [Info] + [AutoCounter] + Activity? PlacingOrder(int orderId, [Baggage] string region); + } + language: csharp + outcome: >- # what the reader gets back + The generator emits the implementation and an AddOrderServiceTelemetry() DI extension. + evidence: >- # a fact, not a claim + One interface replaces the ActivitySource, ILogger, and Meter boilerplate repeated per service. + docsPage: getting-started # slug of an aggregated page under /docs// +``` + +## Rules + +1. **Audience is from a fixed vocabulary** — `developer`, `team-lead`, `architect`, `contributor`. + Pick the person who would actually read this example; do not tag everything `developer`. +2. **At least one use case per non-archived project**, and no project should have a single + `developer` example when the project also has an adoption/architecture story worth telling + (an audit `polish` finding). Do not pad: two or three strong examples beat six weak ones. +3. **`code` requires `language`.** The snippet is highlighted at build time by Shiki; use a real + language id (`csharp`, `json`, `xml`, `yaml`, `bash`, `typescript`). +4. **Snippets are short and point at the docs for depth.** Long walkthroughs belong in the + aggregated documentation. The card links to `docsPage`. +5. **`docsPage` must be a lowercase `[a-z0-9-]` slug** and must resolve to a real aggregated page + (`/docs///`). The schema rejects other shapes, the unit tests reject a + `docsPage` on a project without a `docs` configuration, and the post-build link crawl + (`just check-links`) fails on a dead link. Slug the source file name: `SQL Server Guide.md` → + `sql-server-guide`. +6. **`evidence` must be a fact.** Good: a before/after, a count of generated members, a measured + allocation property, a generated-output excerpt. Bad: "very fast", "easy to use", "saves time". +7. **`scenario` is a situation, `outcome` is the result.** The pair should let a reader decide in ten + seconds whether the example matches their problem. +8. **Keep the language factual** — no marketing superlatives, no invented benchmarks, no claims that + cannot be traced to the repository or the aggregated docs. + +## Where they render + +- Home page ("What it looks like in practice") — server-rendered, no island. +- Project page (`/projects//`) — the use-case section with highlighted code. +- `/use-cases/` — the full index, filterable by audience via one Preact island; the page is usable + without JavaScript and each card carries `data-usecase-audience` and a search corpus. + +## Verification + +```shell +bun run test # manifest/use-case unit tests (audience, language, docsPage rules) +just check-projects # deterministic coverage and docsPage-resolution checks +just build && just check-links # every rendered docs deep link resolves in dist/ +``` diff --git a/skills-lock.json b/skills-lock.json index 0194adf0..ba559af8 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,6 +1,36 @@ { "version": 2, "skills": { + "add-catalogue-project": { + "source": "purview-dev/purview-dev.github.io", + "sourceType": "github", + "skillPath": ".agents/skills/add-catalogue-project/SKILL.md", + "computedHash": "9327e3f3f06cd712ac6720906529eb10593ee6ba6be0d1c52af13c5adb819f2b" + }, + "audit-catalogue-projects": { + "source": "purview-dev/purview-dev.github.io", + "sourceType": "github", + "skillPath": ".agents/skills/audit-catalogue-projects/SKILL.md", + "computedHash": "81cba9a18c65663eb0ef8657086866b53ffbe77d82f9d3d1623fa1bd9fddcdb1" + }, + "docs-aggregation-rules": { + "source": "purview-dev/purview-dev.github.io", + "sourceType": "github", + "skillPath": ".agents/skills/docs-aggregation-rules/SKILL.md", + "computedHash": "01623aa4a66cd0109980b2ea335d259a5bb742530702dfbdce100e5a6d2eba59" + }, + "github-repo-metadata": { + "source": "purview-dev/purview-dev.github.io", + "sourceType": "github", + "skillPath": ".agents/skills/github-repo-metadata/SKILL.md", + "computedHash": "94429f2e0a07fbccbb2af8316759768ce86759ea9557e9118ae3a686f04a3c23" + }, + "project-manifest-reference": { + "source": "purview-dev/purview-dev.github.io", + "sourceType": "github", + "skillPath": ".agents/skills/project-manifest-reference/SKILL.md", + "computedHash": "75a7a807f1df4069dd332d755db049162a0c19a2e284bbbf4ec6bb878a0d8572" + }, "purview-architecture": { "source": "purview-dev/engineering-playbook", "sourceType": "github", @@ -17,43 +47,67 @@ "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-adoption/SKILL.md", - "computedHash": "5ddad1fe7d1dc65c1b352743e5cd616d4720a33228b06faa6259572cd4a8c265" + "computedHash": "ed38157d532c45582bc3f768ca6605c4cbc1339fbbb6a45e34a8badee06af06f" }, "purview-buildsdk-configuration": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-configuration/SKILL.md", - "computedHash": "9b9593e093aaf07ec0a427e7874c1dd2cda35b9a13a6701d7ae10af5ebd985a9" + "computedHash": "7f33e4fee919a7e67033763f76f8e99e585ef128c937c6df58ebdae823949934" + }, + "purview-buildsdk-docs-sync": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-docs-sync/SKILL.md", + "computedHash": "e04a1043a1bf2f70bda95b5a5f544cfd093bd6e3ca159ef1c2425ba6e8129e41" + }, + "purview-buildsdk-justfile": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-justfile/SKILL.md", + "computedHash": "a3eba9ce21459f6db54ea0ecbf1aadfc01109e1446f9ad0142ae0f5ff217a65c" + }, + "purview-buildsdk-msbuild-properties": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-msbuild-properties/SKILL.md", + "computedHash": "76af7d5e1d12fd159acea43d3591bb6543b7ecb8375f3cfdc2f090f28729431f" }, "purview-buildsdk-project-detection": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-project-detection/SKILL.md", - "computedHash": "9a3cf78ad4df9c335780bbbfbc9f66f7986b6293359aab19edaadb5c79f70be8" + "computedHash": "8b39fdeba80cad1cfd19d394209affc95d2dbc3e6a2e0358d14dc54ba6892ed7" }, "purview-buildsdk-project-layout": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-project-layout/SKILL.md", - "computedHash": "d8b1fb6b95ae5e91f81734616f70a9f3d2d7547dc7406e4eda0f8d674ebf808b" + "computedHash": "e2df530972d850894ea3129f30041b5f0a16a067b6a6b037188f920e81d488bb" }, "purview-buildsdk-repository-review": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-repository-review/SKILL.md", - "computedHash": "babfe657a1eef5be67dc1e4ec387e9e8215fb0263956c4c926465fa27b648604" + "computedHash": "08a5b7a34511959b72702ae8107c15ece77a27a7ef58c0b6b08e25a206f76c25" + }, + "purview-buildsdk-settings-docs-sync": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": ".agents/skills/purview-buildsdk-settings-docs-sync/SKILL.md", + "computedHash": "9ad1c2c1fbe057903021f002ff8053c9c3d5563052808d04acf77e8ec11a1237" }, "purview-buildsdk-skill-installation": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-skill-installation/SKILL.md", - "computedHash": "8e1dc7c0f499eb742264d5cc293784758c5f0499f849671a0be3353f91f89491" + "computedHash": "5a8cffd36dc696ec86276ddd089e9f859d4571b7606b46c0b82a5c52d5935e16" }, "purview-buildsdk-skill-tests": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-skill-tests/SKILL.md", - "computedHash": "27b3520ced5c5a8009cfb47149f0fc1826246275d3ec2991667e1543b011d657" + "computedHash": "bfa5798b756ab0fdc174fcba98184a516135f61f5b99453fc62b042a5c3551fa" }, "purview-buildsdk-skills-justfile": { "source": "purview-dev/build-sdk", @@ -65,7 +119,7 @@ "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-testing/SKILL.md", - "computedHash": "e7e00ede43f5ee8aa4517329e4fdbde00a5a01bd8e98ea42990c851060bcaf9e" + "computedHash": "5faaf12203c835f1af452137304feed6b99c5e94d7fb1e628ba0b304751914f9" }, "purview-component-compatibility": { "source": "purview-dev/engineering-playbook", @@ -91,6 +145,24 @@ "skillPath": "skills/purview-persistence/SKILL.md", "computedHash": "28e92fa8bccc241ca864b6731148bd721f47699baf3a1a2ec07cce42ed650bae" }, + "purview-site-assessment": { + "source": "purview-dev/purview-dev.github.io", + "sourceType": "github", + "skillPath": "skills/purview-site-assessment/SKILL.md", + "computedHash": "6112f08130fda26f0ef5fafbb7084d20b758c1cd5edd70d7a689d7933c6b2afa" + }, + "purview-site-docs": { + "source": "purview-dev/purview-dev.github.io", + "sourceType": "github", + "skillPath": "skills/purview-site-docs/SKILL.md", + "computedHash": "b11f8d1d71e5c07b978dd8308a15896890a2384f3545075d582a935565fb1ca1" + }, + "purview-site-integration": { + "source": "purview-dev/purview-dev.github.io", + "sourceType": "github", + "skillPath": "skills/purview-site-integration/SKILL.md", + "computedHash": "56ed9760a93e60a2abae8d790eb6405b93950d9de010e22ce3d5cf9e10e25802" + }, "purview-sourcegenerator-attribute-data-models": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", @@ -101,13 +173,13 @@ "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-codewriter/SKILL.md", - "computedHash": "5874c7773f395a5fea123f447bc500a687760f256eb5f7646022f9fbd22cd111" + "computedHash": "efc58013ff2122eb56981d482f8b66345977f235339473d5db63afca45bee198" }, "purview-sourcegenerator-diagnostics": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-diagnostics/SKILL.md", - "computedHash": "8e3571221c2a2225f3826b0c501cd28ea2a27e91defd121f45117c0e36a308fd" + "computedHash": "751a6feac6487bb70dba6ceb9b8bfb7201bae9e3733e1634df7dea443fe4f6a1" }, "purview-sourcegenerator-packaging": { "source": "purview-dev/sourcegenerator-framework", @@ -125,7 +197,7 @@ "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-testing/SKILL.md", - "computedHash": "ef37df157bd9a589d487e79cffdecc4160aee59ca9000aea55d7204af097e883" + "computedHash": "027d6eb1b63783635b9000acd72ec4c3d06e129fed742e7b71a79fe7535040ed" }, "purview-sourcegenerator-tunit-tests": { "source": "purview-dev/sourcegenerator-framework", @@ -144,6 +216,18 @@ "sourceType": "github", "skillPath": "skills/purview-testing-conventions/SKILL.md", "computedHash": "e053513db856852a23aad0e22625e1408fdd63377acb793e6d720fe37b9f7214" + }, + "site-validation-loop": { + "source": "purview-dev/purview-dev.github.io", + "sourceType": "github", + "skillPath": ".agents/skills/site-validation-loop/SKILL.md", + "computedHash": "fbeadc96c6cf59d54cd037b5084aa3d0fb1dd89542705dd1c3a8ca5704b5b5da" + }, + "use-case-authoring": { + "source": "purview-dev/purview-dev.github.io", + "sourceType": "github", + "skillPath": ".agents/skills/use-case-authoring/SKILL.md", + "computedHash": "c5c10d3a2b0ba328d76c28fe6502d45f6f2f338fb047975e6edf6f603ac2c50d" } } } From c317e502e604109c65f340613206324aaae761e3 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sun, 15 Nov 2026 00:10:28 +0000 Subject: [PATCH 02/12] fix(unions): declare a union accessor once per generated class Every target emitter emitted the UnwrapUnion_ accessor into its own hint file. Activity, Logging and Metric are one partial class, so a union consumed by two target families was declared twice and the consumer failed with CS0111, then CS0121 at the now-ambiguous call sites. 5.2.1 unwrapped inline, so this regressed in 6.0.0. The accessor is a shared member like the dependency-injection extension and the constructor, so it now follows the same ownership rule: the canonical target (Activities, else Logging, else Metrics) declares it once per union type and the other files call it. UnionAccessorPlanner decides the list: - one registered target: the target's own unions, so a single-target interface's output is unchanged and no consumer needs a change; - more than one: the interface's unions in declaration order, ignoring parameter [ExcludeTargets]. A target never sees a method that belongs to another family only ([Logger][Meter] with a union on the [Counter] method), and an Activities-excluded parameter is still unwrapped by the log or metric call site. Also fixes [Event] tags recording the union struct instead of its case value: event tags are collected into an ActivityTagsCollection rather than set through SetTag, and that was the one call site still using the raw parameter. Regression tests (red on 6.0.0 with the reported ids) cover both reported interfaces, the six-shape emission matrix, the [Logger][Meter] ownership case, single-target placement and no new TSG diagnostics. The packed Native AOT and Blazor consumers carry the shape end to end, with warnings as errors. Verified against the reporter's solution: ChangeOps builds on 6.0.1 with one declaration per union and no CS0111. --- AGENTS.md | 13 +- CHANGELOG.md | 12 + docs/wiki/Generated-Output.md | 6 + docs/wiki/Multi-Targeting.md | 17 + docs/wiki/Tags-and-Baggage.md | 4 +- package.json | 2 +- .../BlazorWasm/BlazorWasm.csproj | 2 +- .../packed-consumers/Shared/Expectations.cs | 40 ++ .../Shared/IOrderTelemetry.cs | 17 + .../packed-consumers/Shared/OrderRejection.cs | 52 ++ .../packed-consumers/Shared/OrderWorkflow.cs | 2 + skills/purview-telemetry-design/SKILL.md | 5 + ...tySourceTargetClassEmitter.EventMethods.cs | 2 +- .../ActivitySourceTargetClassEmitter.cs | 5 +- .../Emitters/LoggerGenTargetClassEmitter.cs | 5 +- .../Emitters/LoggerTargetClassEmitter.cs | 5 +- .../Emitters/MeterTargetClassEmitter.cs | 5 +- .../Emitters/UnionAccessorEmitter.cs | 21 +- .../Helpers/PipelineHelpers.Activities.cs | 9 +- .../Helpers/PipelineHelpers.Logger.cs | 9 +- .../Helpers/PipelineHelpers.Metrics.cs | 9 +- .../SourceGenerator/Helpers/SharedHelpers.cs | 11 +- .../Helpers/UnionAccessorPlanner.cs | 95 +++ .../Records/ActivityRecords.cs | 3 +- .../SourceGenerator/Records/LoggerRecords.cs | 3 +- .../SourceGenerator/Records/MetricRecords.cs | 3 +- ...rySourceGeneratorUnionTests.MultiTarget.cs | 559 ++++++++++++++++++ 27 files changed, 870 insertions(+), 46 deletions(-) create mode 100644 scripts/packed-consumers/Shared/OrderRejection.cs create mode 100644 src/src/SourceGenerator/Helpers/UnionAccessorPlanner.cs create mode 100644 src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorUnionTests.MultiTarget.cs diff --git a/AGENTS.md b/AGENTS.md index d4790466..0140a364 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -226,10 +226,15 @@ table of gates is in [`docs/wiki/Release-Flow.md`](docs/wiki/Release-Flow.md#rel recorded as its case value, never as the union struct. `Helpers/UnionHelpers.cs` detects it (case types from the nested `IUnionMembers` provider when there is one, otherwise from the union's single-parameter constructors, and whether it declares the non-boxing `HasValue`/`TryGetValue` pattern); - `Emitters/UnionAccessorEmitter.cs` emits one private accessor per union type into each generated class; - every target renders the value through `UnionTargetRecord.RenderValueExpression()` rather than the - parameter name. Detection is by symbol, never by target framework. Whether a union is a *declared* - `union` or hand-written decides only whether the accessor reads `TryGetValue` or `Value`. + `Emitters/UnionAccessorEmitter.cs` emits one private accessor per union type into the generated class, and + `Helpers/UnionAccessorPlanner.cs` decides that only the canonical target declares it — the class is one + `partial` across the target files, so a second declaration is `CS0111` for the consumer. The owning target's + list is the interface's unions when the interface registers more than one target, and its own parameters' + unions when it registers one, so a single-target interface's output is unchanged. Every target renders the + value through `UnionTargetRecord.RenderValueExpression()` rather than the parameter name — including an + `[Event]` tag, which is collected into an `ActivityTagsCollection` rather than set through `SetTag`. + Detection is by symbol, never by target framework. Whether a union is a *declared* `union` or hand-written + decides only whether the accessor reads `TryGetValue` or `Value`. - **SDK**: projects import `Purview.BuildSdk` via `Directory.Build.props`/`.targets`. The repo sets `ExcludePurviewTelemetry=true` (it does not consume the telemetry package it generates) and `NamespacePrefix=Purview.Telemetry` under `src/`. See `.agents/skills/purview-buildsdk-*` for SDK behavior. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f0343c9..ebf9f0c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,18 @@ and, for breaking changes, by [Breaking Changes](docs/wiki/Breaking-Changes.md). ### Fixed +- **A union-typed parameter on an interface that registers more than one target no longer produces `CS0111`.** Each + target emitted the `UnwrapUnion_` accessor into its own hint file, and Activity, Logging and Metric are one + `partial` class, so an interface such as `[ActivitySource][Logger]` with a union on a method that both records an + event and logs declared the same helper twice — and every call site then failed with `CS0121`, "the call is + ambiguous". The accessor is a shared member like the dependency-injection extension and the constructor, so it now + follows the same ownership rule: the canonical target (Activities, else Logging, else Metrics) declares it once per + union type and the other files call it. With more than one target the list is the *interface's* unions rather than the + owning target's own parameters, because a target never sees a method that belongs to another family only, and a + parameter excluded from Activities is still unwrapped by the log or metric call site. A single-target interface's + output is unchanged, so no consumer needs to change its interfaces, add `[ExcludeTargets]`, or suppress `CS0111`. + `[Event]` tags now unwrap a union to its case value as well: they are collected into an `ActivityTagsCollection` + rather than set through `SetTag`, and that was the one call site still recording the union struct itself. - **Purview.Unions `[GenerateUnion]` unions used as telemetry parameters are unwrapped to their case value.** Union recognition only looked for `[System.Runtime.CompilerServices.Union]`, which the unions generator emits into *generated* output — output Roslyn never shows another generator — so a `[GenerateUnion]` parameter was recorded as the diff --git a/docs/wiki/Generated-Output.md b/docs/wiki/Generated-Output.md index a4ab6936..d52931fa 100644 --- a/docs/wiki/Generated-Output.md +++ b/docs/wiki/Generated-Output.md @@ -30,6 +30,12 @@ interface IEntityStoreTelemetry generates one partial `EntityStoreTelemetryCore` class split across Activity, Logging, and Metric files, plus a DI extension class and an assembly-level `[GeneratedTelemetryNames]` attribute recording the generated names. +> [!NOTE] +> Because the class is `partial` across those files, a member that belongs to the class rather than to one target is +> declared by exactly one of them: the class attributes and the DI extension by the canonical target (Activities, else +> Logging, else Metrics), the constructor by Logging or Metrics, and a union accessor (`UnwrapUnion_`) by the +> canonical target, once per union type. See [Shared members](Multi-Targeting.md#shared-members-and-which-file-declares-them). + > [!NOTE] > To inspect the generated output in your own project, enable `EmitCompilerGeneratedFiles` and look under `obj///generated/Purview.Telemetry.SourceGenerator/Purview.Telemetry.SourceGenerator.TelemetrySourceGenerator/`. diff --git a/docs/wiki/Multi-Targeting.md b/docs/wiki/Multi-Targeting.md index bcbe9fbb..0e48b1ad 100644 --- a/docs/wiki/Multi-Targeting.md +++ b/docs/wiki/Multi-Targeting.md @@ -248,6 +248,23 @@ Activity? ApiCall( ); ``` +## Shared members and which file declares them + +One interface generates one `partial` class spread across a file per target (`*.Activity.g.cs`, `*.Logging.g.cs`, +`*.Metric.g.cs`). A member that belongs to the class rather than to one target is therefore declared by exactly one of +them, so the consumer never sees a duplicate definition (`CS0111`): + +| Member | Declared by | +| --- | --- | +| Class attributes, dependency-injection extension | the canonical target: Activities, else Logging, else Metrics | +| Constructor | Logging, else Metrics (Activities never owns it) | +| Union accessor (`UnwrapUnion_`) | the canonical target, once per union type on the interface | + +The union accessor is the same rule with one addition: the list is the *interface's* unions, not the owning target's own +parameters, because a union consumed by a `[Warning]` log entry is still owned by the Activities file on an interface that +also registers `[ActivitySource]`, and a target never sees the parameters of a method that targets only another family. +`[ExcludeTargets]` on the parameter does not move it: the other family's call site still unwraps the value. + ## Benefits 1. **Less code** — one method definition generates multiple telemetry types diff --git a/docs/wiki/Tags-and-Baggage.md b/docs/wiki/Tags-and-Baggage.md index 50b8ec9e..7cf7f671 100644 --- a/docs/wiki/Tags-and-Baggage.md +++ b/docs/wiki/Tags-and-Baggage.md @@ -123,7 +123,9 @@ Marks a `string` parameter as the status description for an Event that sets an e A parameter whose type is a union is recorded as its **case value**, never as the union struct. The generator emits one private, aggressively inlined accessor per union type and calls it wherever the parameter's value is recorded, so a -union behaves like the value it holds: +union behaves like the value it holds. The accessor is declared once per generated class, in the file of the target that +owns shared members (Activities, else Logging, else Metrics), and every other target's file calls it — see +[Shared members](Multi-Targeting.md#shared-members-and-which-file-declares-them). ```csharp public record class Ordered(string OrderId); diff --git a/package.json b/package.json index 578ac05b..f62ea81c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "telemetry-sourcegenerator", - "version": "6.0.0", + "version": "6.0.1", "description": "Generates [`ActivitySource`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitysource), [`ILogger`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.ilogger), and [`Metrics`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics) based on interface methods.", "license": "MIT", "readme": "README.md", diff --git a/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj b/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj index 67a7de52..80721e44 100644 --- a/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj +++ b/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj @@ -25,7 +25,7 @@ diff --git a/scripts/packed-consumers/Shared/Expectations.cs b/scripts/packed-consumers/Shared/Expectations.cs index e4ce60b8..89d88510 100644 --- a/scripts/packed-consumers/Shared/Expectations.cs +++ b/scripts/packed-consumers/Shared/Expectations.cs @@ -69,6 +69,27 @@ void Check(bool condition, string failure) $"exception event tag order_id is '{Get(exception[0].Tags, "order_id")}'" ); } + + // A union parameter is recorded as its case value, never as the union struct, and a parameter excluded from + // Activities is not applied to the event even though the same method logs it. + var rejected = activity.Events.Where(e => e.Name == "order.rejected").ToArray(); + Check(rejected.Length == 1, $"expected one order.rejected event, got {rejected.Length}"); + if (rejected.Length == 1) + { + var rejection = Get(rejected[0].Tags, "rejection"); + Check( + rejection?.Contains("card declined", StringComparison.Ordinal) == true, + $"order.rejected tag rejection is the union's case value only when it reads '{rejection}'" + ); + Check( + !rejection!.Contains("OrderRejection", StringComparison.Ordinal), + $"order.rejected tag rejection is the union struct itself: '{rejection}'" + ); + Check( + Get(rejected[0].Tags, "rejection_id") is null, + "order.rejected recorded rejection_id although it is excluded from Activities" + ); + } } var reserve = capture.Activities.Where(a => a.Source == InventoryName && a.Name == "stock.reserve").ToArray(); @@ -182,6 +203,25 @@ void Check(bool condition, string failure) "the OrderFailed log entry does not carry the exception" ); + var rejectedLogs = logs.Where(l => l.EventName?.Contains("OrderRejected", StringComparison.Ordinal) == true) + .ToArray(); + Check( + rejectedLogs.Length == 1 && rejectedLogs[0].Level == LogLevel.Warning, + $"expected one Warning OrderRejected log entry, got {Describe(rejectedLogs)}" + ); + if (rejectedLogs.Length == 1) + { + var rejection = Get(rejectedLogs[0].State, "Rejection"); + Check( + rejection?.Contains("card declined", StringComparison.Ordinal) == true, + $"the OrderRejected state Rejection is '{rejection}', not the union's case value" + ); + Check( + Get(rejectedLogs[0].State, "RejectionId") == orderId, + $"the OrderRejected state RejectionId is '{Get(rejectedLogs[0].State, "RejectionId")}'" + ); + } + return failures; } diff --git a/scripts/packed-consumers/Shared/IOrderTelemetry.cs b/scripts/packed-consumers/Shared/IOrderTelemetry.cs index 6723c249..4824d08c 100644 --- a/scripts/packed-consumers/Shared/IOrderTelemetry.cs +++ b/scripts/packed-consumers/Shared/IOrderTelemetry.cs @@ -52,4 +52,21 @@ public interface IOrderTelemetry [Event("exception")] [Error] void OrderFailed(Activity? activity, Exception exception, [Tag] string orderId); + + /// + /// Multi-target: adds an ActivityEvent and logs at Warning, each carrying a union-typed parameter as its case value. + /// + /// + /// This is the shape that regressed in 6.0.0: a union consumed by two target families was declared in both + /// generated partial files (CS0111). Every warning is an error here, so the gate fails on it. The + /// rejectionId parameter is excluded from Activities as well, so the accessor has to be owned by a target + /// that the excluded-from family does not itself use. + /// + [Event("order.rejected")] + [Warning] + void OrderRejected( + Activity? activity, + [ExcludeTargets(Targets.Activities)] string rejectionId, + OrderRejection rejection + ); } diff --git a/scripts/packed-consumers/Shared/OrderRejection.cs b/scripts/packed-consumers/Shared/OrderRejection.cs new file mode 100644 index 00000000..0371238e --- /dev/null +++ b/scripts/packed-consumers/Shared/OrderRejection.cs @@ -0,0 +1,52 @@ +namespace PackedTelemetry; + +/// +/// A hand-written C# 15 union used as a telemetry parameter, so the packed generator's accessor is compiled into every +/// consumer. It declares the non-boxing access pattern ( plus one TryGetValue per case), so +/// the generated accessor reads the case without boxing the union. +/// +/// +/// Hand-written rather than declared with the union keyword: the shape is then identical on every compiler that +/// can see this file, which is what the consumers assert. Only the case value must reach the event and the log entry. +/// +[System.Runtime.CompilerServices.Union] +public readonly struct OrderRejection +{ + readonly object? _value; + + public OrderRejection(CardDeclined value) => _value = value; + + public OrderRejection(OutOfStock value) => _value = value; + + public object? Value => _value; + + public bool HasValue => _value is not null; + + public bool TryGetValue(out CardDeclined value) + { + if (_value is CardDeclined declined) + { + value = declined; + return true; + } + + value = null!; + return false; + } + + public bool TryGetValue(out OutOfStock value) + { + if (_value is OutOfStock outOfStock) + { + value = outOfStock; + return true; + } + + value = null!; + return false; + } +} + +public sealed record CardDeclined(string Reason); + +public sealed record OutOfStock(string Sku); diff --git a/scripts/packed-consumers/Shared/OrderWorkflow.cs b/scripts/packed-consumers/Shared/OrderWorkflow.cs index 857d710d..bd0de3ee 100644 --- a/scripts/packed-consumers/Shared/OrderWorkflow.cs +++ b/scripts/packed-consumers/Shared/OrderWorkflow.cs @@ -27,6 +27,8 @@ public void Place(string orderId, string customerId, double total) { telemetry.OrderFailed(activity, exception, orderId); } + + telemetry.OrderRejected(activity, orderId, new OrderRejection(new CardDeclined("card declined"))); } telemetry.OrdersInFlight(-1); } diff --git a/skills/purview-telemetry-design/SKILL.md b/skills/purview-telemetry-design/SKILL.md index a0e7d862..7b885906 100644 --- a/skills/purview-telemetry-design/SKILL.md +++ b/skills/purview-telemetry-design/SKILL.md @@ -99,6 +99,11 @@ the union's own `TryGetValue` overloads when it declares them (a `bool HasValue` per case), so the union struct is never boxed to read it. A union that holds no value records `null`. Works on every supported target, `net48` included: detection is by symbol, not by framework. +One interface generates one `partial` class across its target files, so the accessor is declared **once**: by the target +that owns shared members (Activities, else Logging, else Metrics), for every union the interface declares. The other +files call it. A `[ExcludeTargets(Targets.Activities)]` parameter does not move it — the log or metric call site still +unwraps the value. + ```csharp public union OrderOutcome(OrderPlaced, OrderRejected); diff --git a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.EventMethods.cs b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.EventMethods.cs index 131b6e1b..c3a1a23e 100644 --- a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.EventMethods.cs +++ b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.EventMethods.cs @@ -180,7 +180,7 @@ void EmitTag() .Write(".Add(") .Write(tagParam.GeneratedName.Wrap()) .Write(", ") - .Write(tagParam.ParameterName) + .Write(tagParam.RenderValueExpression()) .Line(");"); } } diff --git a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.cs b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.cs index c8de928f..27c94693 100644 --- a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.cs +++ b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.cs @@ -39,10 +39,7 @@ public static void GenerateImplementation(ActivityOutputContext output, SourcePr ) { EmitFields(output, writer, context); - UnionAccessorEmitter.EmitAccessors( - writer, - target.ActivityMethods.SelectMany(static m => m.Parameters).Select(static p => p.Union) - ); + UnionAccessorEmitter.EmitAccessors(writer, target.UnionAccessors); EmitMethods(output, writer, context); } diff --git a/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.cs b/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.cs index c8dc944e..97a4ffe0 100644 --- a/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.cs +++ b/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.cs @@ -42,10 +42,7 @@ public static void GenerateImplementation(LoggerOutputContext output, SourceProd { EmitFields(output, writer, context); - UnionAccessorEmitter.EmitAccessors( - writer, - target.LogMethods.SelectMany(static m => m.Parameters).Select(static p => p.Union) - ); + UnionAccessorEmitter.EmitAccessors(writer, target.UnionAccessors); ConstructorEmitter.EmitCtor( GenerationType.Logging, diff --git a/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.cs b/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.cs index cc105b53..e95d7698 100644 --- a/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.cs +++ b/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.cs @@ -40,10 +40,7 @@ public static void GenerateImplementation(LoggerOutputContext output, SourceProd { EmitFields(output, writer, context); - UnionAccessorEmitter.EmitAccessors( - writer, - target.LogMethods.SelectMany(static m => m.Parameters).Select(static p => p.Union) - ); + UnionAccessorEmitter.EmitAccessors(writer, target.UnionAccessors); ConstructorEmitter.EmitCtor( GenerationType.Logging, diff --git a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.cs b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.cs index 7c9b2132..8bf2af5e 100644 --- a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.cs +++ b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.cs @@ -59,10 +59,7 @@ public static void GenerateImplementation(MeterOutputContext output, SourceProdu EmitFields(output, writer, context, readonlyFields: metricsOwnsConstructor); - UnionAccessorEmitter.EmitAccessors( - writer, - target.InstrumentationMethods.SelectMany(static m => m.Parameters).Select(static p => p.Union) - ); + UnionAccessorEmitter.EmitAccessors(writer, target.UnionAccessors); if (metricsOwnsConstructor) EmitInlineConstructor(output, writer, context); diff --git a/src/src/SourceGenerator/Emitters/UnionAccessorEmitter.cs b/src/src/SourceGenerator/Emitters/UnionAccessorEmitter.cs index f4d7ea9a..086fd9e0 100644 --- a/src/src/SourceGenerator/Emitters/UnionAccessorEmitter.cs +++ b/src/src/SourceGenerator/Emitters/UnionAccessorEmitter.cs @@ -10,28 +10,19 @@ namespace Purview.Telemetry.SourceGenerator.Emitters; /// /// One accessor is emitted per distinct union type in the class, so the read logic exists once no matter how /// many methods and targets use the union, and the emitted method is small enough for the JIT to inline into -/// every call site. Nothing here reflects over the union: every case is a compile-time TryGetValue +/// every call site. The class is one partial type across the target files, so the list is not derived here: +/// decides which target owns the declarations and hands only that target a +/// non-empty list. Nothing here reflects over the union: every case is a compile-time TryGetValue /// overload or the union's own Value property, so the generated code stays trim- and AOT-safe. /// static class UnionAccessorEmitter { - public static void EmitAccessors(CodeWriter writer, IEnumerable unions) + public static void EmitAccessors(CodeWriter writer, EquatableArray unions) { - List distinct = []; - HashSet emitted = [with(StringComparer.Ordinal)]; - - foreach (var union in unions) - { - if (union is null || !emitted.Add(union.AccessorName)) - continue; - - distinct.Add(union); - } - - if (distinct.Count == 0) + if (unions.IsEmpty) return; - foreach (var union in distinct) + foreach (var union in unions) EmitAccessor(writer, union); } diff --git a/src/src/SourceGenerator/Helpers/PipelineHelpers.Activities.cs b/src/src/SourceGenerator/Helpers/PipelineHelpers.Activities.cs index bae1802a..864f8847 100644 --- a/src/src/SourceGenerator/Helpers/PipelineHelpers.Activities.cs +++ b/src/src/SourceGenerator/Helpers/PipelineHelpers.Activities.cs @@ -91,7 +91,14 @@ is not InterfaceDeclarationSyntax interfaceDeclaration ActivitySourceName: activitySourceName, ActivityMethods: activityMethods, ActivityTargetAttributeRecord: activitySourceAttribute, - InterfaceTypeParameters: TypeParameterHelpers.FromNamedType(interfaceSymbol) + InterfaceTypeParameters: TypeParameterHelpers.FromNamedType(interfaceSymbol), + UnionAccessors: UnionAccessorPlanner.Plan( + interfaceSymbol, + generationType, + GenerationType.Activities, + activityMethods.SelectMany(static method => method.Parameters).Select(static p => p.Union), + token + ) ), TelemetryRules.GetInterfaceLevelDiagnostics(interfaceSymbol, compilation, token) ); diff --git a/src/src/SourceGenerator/Helpers/PipelineHelpers.Logger.cs b/src/src/SourceGenerator/Helpers/PipelineHelpers.Logger.cs index 891d8b93..9f62a36c 100644 --- a/src/src/SourceGenerator/Helpers/PipelineHelpers.Logger.cs +++ b/src/src/SourceGenerator/Helpers/PipelineHelpers.Logger.cs @@ -97,7 +97,14 @@ is not InterfaceDeclarationSyntax interfaceDeclaration DefaultLevel: defaultLogLevel, LogMethods: logMethods, UseMSLoggingTelemetryBasedGeneration: interfaceGenerationMode != 1 || requiresStateGeneration, - InterfaceTypeParameters: TypeParameterHelpers.FromNamedType(interfaceSymbol) + InterfaceTypeParameters: TypeParameterHelpers.FromNamedType(interfaceSymbol), + UnionAccessors: UnionAccessorPlanner.Plan( + interfaceSymbol, + generationType, + GenerationType.Logging, + logMethods.SelectMany(static method => method.Parameters).Select(static p => p.Union), + token + ) ), TelemetryRules.GetInterfaceLevelDiagnostics(interfaceSymbol, compilation, token) ); diff --git a/src/src/SourceGenerator/Helpers/PipelineHelpers.Metrics.cs b/src/src/SourceGenerator/Helpers/PipelineHelpers.Metrics.cs index 9404856e..1e4425e4 100644 --- a/src/src/SourceGenerator/Helpers/PipelineHelpers.Metrics.cs +++ b/src/src/SourceGenerator/Helpers/PipelineHelpers.Metrics.cs @@ -104,7 +104,14 @@ is not InterfaceDeclarationSyntax interfaceDeclaration MeterVersion: SharedHelpers.NullIfWhitespace(meterAttribute.Version) ?? SharedHelpers.NullIfWhitespace(meterGenerationAttribute?.Version), MeterTags: SharedHelpers.MergeTags(meterGenerationAttribute?.Tags, meterAttribute.Tags), - InterfaceTypeParameters: TypeParameterHelpers.FromNamedType(interfaceSymbol) + InterfaceTypeParameters: TypeParameterHelpers.FromNamedType(interfaceSymbol), + UnionAccessors: UnionAccessorPlanner.Plan( + interfaceSymbol, + generationType, + GenerationType.Metrics, + instrumentMethods.SelectMany(static method => method.Parameters).Select(static p => p.Union), + token + ) ), TelemetryRules.GetInterfaceLevelDiagnostics(interfaceSymbol, compilation, token) ); diff --git a/src/src/SourceGenerator/Helpers/SharedHelpers.cs b/src/src/SourceGenerator/Helpers/SharedHelpers.cs index 53689aa3..74cab572 100644 --- a/src/src/SourceGenerator/Helpers/SharedHelpers.cs +++ b/src/src/SourceGenerator/Helpers/SharedHelpers.cs @@ -164,7 +164,16 @@ public static bool ShouldEmitDIExtension(GenerationType requestingType, Generati && GetCanonicalTargetType(generationType, includeActivities: true) == requestingType; /// - /// Determines if class-level attributes should be emitted for this requesting type. + /// Determines if the union accessors should be emitted for this requesting type. + /// Only one target should emit them to avoid duplicate definitions across the generated + /// partial classes (CS0111). + /// + public static bool ShouldEmitUnionAccessors(GenerationType requestingType, GenerationType generationType) => + requestingType != GenerationType.None + && GetCanonicalTargetType(generationType, includeActivities: true) == requestingType; + + /// + /// Determines if the class-level attributes should be emitted for this requesting type. /// Only one target should emit class attributes to avoid duplicate attributes on partial classes. /// public static bool ShouldEmitClassAttributes(GenerationType requestingType, GenerationType generationType) => diff --git a/src/src/SourceGenerator/Helpers/UnionAccessorPlanner.cs b/src/src/SourceGenerator/Helpers/UnionAccessorPlanner.cs new file mode 100644 index 00000000..5c67fd91 --- /dev/null +++ b/src/src/SourceGenerator/Helpers/UnionAccessorPlanner.cs @@ -0,0 +1,95 @@ +using Microsoft.CodeAnalysis; +using Purview.Telemetry.SourceGenerator.Records; + +namespace Purview.Telemetry.SourceGenerator.Helpers; + +/// +/// Decides which union accessors a target emits. +/// +/// +/// The generated telemetry class is one partial type spread across the target files +/// (*.Activity.g.cs, *.Logging.g.cs, *.Metric.g.cs), so a private accessor declared by two of them +/// is CS0111 for the consumer. The accessor is a shared member like the dependency-injection extension, the class +/// attributes and the constructor, and follows the same ownership rule: the canonical target +/// (Activities > Logging > Metrics) declares it, once per union type. The other targets call it, and a call from +/// another partial resolves because the class is one type. +/// +/// A target cannot decide this from its own methods: the Logging target never sees a metric-only method's parameters, +/// and a parameter excluded from Activities is still unwrapped by the log or metric call site. So for a multi-target +/// interface the list is the interface's own, in declaration order, regardless of which targets consume each union. +/// +/// +static class UnionAccessorPlanner +{ + /// + /// The accessors must emit, de-duplicated by accessor name and in declaration order. + /// Empty when the target does not own them. + /// + /// The interface being generated. + /// Every target the interface registers. + /// The target being built. + /// The unions the target's own method models carry. + /// The cancellation token. + public static EquatableArray Plan( + INamedTypeSymbol interfaceSymbol, + GenerationType generationType, + GenerationType target, + IEnumerable ownUnions, + CancellationToken token + ) + { + token.ThrowIfCancellationRequested(); + + if (!SharedHelpers.ShouldEmitUnionAccessors(target, generationType)) + return default; + + // One registered target has one generated file, so its own method list is the whole picture. Keeping that + // derivation unchanged is what makes a single-target interface's output identical to a per-target one. + if (generationType == target) + return Distinct(ownUnions); + + return Distinct(Collect(interfaceSymbol, token)); + } + + /// + /// Every union-typed parameter the interface declares, in declaration order. + /// + /// + /// Parameter-level [ExcludeTargets] is deliberately ignored: it only stops a value being recorded by one + /// target family, and the owning target still declares the accessor the excluded-from target's file calls. + /// + static EquatableArray Collect(INamedTypeSymbol interfaceSymbol, CancellationToken token) + { + List unions = []; + + foreach (var method in PipelineHelpers.GetAllInterfaceMethods(interfaceSymbol, token)) + { + token.ThrowIfCancellationRequested(); + + // An excluded method is not implemented by the generated class, so it has no call site for the accessor. + if (Utilities.ContainsAttribute(method, TypeLibrary.Purview.Telemetry.ExcludeAttribute, token)) + continue; + + foreach (var parameter in method.Parameters) + unions.Add(UnionHelpers.GetUnionTarget(parameter.Type, token)); + } + + return Distinct(unions); + } + + static EquatableArray Distinct(IEnumerable unions) + { + List distinct = []; + HashSet seen = [with(StringComparer.Ordinal)]; + + foreach (var union in unions) + { + if (union is null || !seen.Add(union.AccessorName)) + continue; + + distinct.Add(union); + } + + return new([.. distinct]); + } +} diff --git a/src/src/SourceGenerator/Records/ActivityRecords.cs b/src/src/SourceGenerator/Records/ActivityRecords.cs index 70ba604a..295007b2 100644 --- a/src/src/SourceGenerator/Records/ActivityRecords.cs +++ b/src/src/SourceGenerator/Records/ActivityRecords.cs @@ -10,7 +10,8 @@ sealed record ActivitySourceTarget( string? ActivitySourceName, EquatableArray ActivityMethods, ActivitySourceAttributeData ActivityTargetAttributeRecord, - EquatableArray InterfaceTypeParameters = default + EquatableArray InterfaceTypeParameters = default, + EquatableArray UnionAccessors = default ) { public string? ClassNamespace => TelemetryGeneration.TelemetryNamesNamespace ?? InterfaceType.Identity.Namespace; diff --git a/src/src/SourceGenerator/Records/LoggerRecords.cs b/src/src/SourceGenerator/Records/LoggerRecords.cs index 04e3abbe..94e0e2c3 100644 --- a/src/src/SourceGenerator/Records/LoggerRecords.cs +++ b/src/src/SourceGenerator/Records/LoggerRecords.cs @@ -10,7 +10,8 @@ sealed record LoggerTarget( int DefaultLevel, EquatableArray LogMethods, bool UseMSLoggingTelemetryBasedGeneration, - EquatableArray InterfaceTypeParameters = default + EquatableArray InterfaceTypeParameters = default, + EquatableArray UnionAccessors = default ) { public string? ClassNamespace => TelemetryGeneration.TelemetryNamesNamespace ?? InterfaceType.Identity.Namespace; diff --git a/src/src/SourceGenerator/Records/MetricRecords.cs b/src/src/SourceGenerator/Records/MetricRecords.cs index ca7ae618..20bccb10 100644 --- a/src/src/SourceGenerator/Records/MetricRecords.cs +++ b/src/src/SourceGenerator/Records/MetricRecords.cs @@ -11,7 +11,8 @@ sealed record MeterTarget( EquatableArray InstrumentationMethods, string? MeterVersion = null, EquatableArray MeterTags = default, - EquatableArray InterfaceTypeParameters = default + EquatableArray InterfaceTypeParameters = default, + EquatableArray UnionAccessors = default ) { public string? ClassNamespace => TelemetryGeneration.TelemetryNamesNamespace ?? InterfaceType.Identity.Namespace; diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorUnionTests.MultiTarget.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorUnionTests.MultiTarget.cs new file mode 100644 index 00000000..a5fc938b --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorUnionTests.MultiTarget.cs @@ -0,0 +1,559 @@ +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Purview.Telemetry.SourceGenerator.Infra; + +namespace Purview.Telemetry.SourceGenerator.Generators; + +/// +/// A union-typed parameter on an interface that registers more than one target (Activities, Logging, Metrics). +/// +/// +/// The generated class is a partial across the target files (*.Activity.g.cs, *.Logging.g.cs, +/// *.Metric.g.cs), so a helper declared by two of them is CS0111 for the consumer. The accessor is a +/// shared member like the DI extension, the class attributes and the constructor, and follows the same ownership rule: +/// exactly one target (Activities > Logging > Metrics) declares it, once per union type. +/// +/// The regression these tests pin shipped in 6.0.0: each target emitted the accessor into its own file from its own +/// method list, so an interface with two targets declared the same UnwrapUnion_* twice. +/// +/// +public partial class TelemetrySourceGeneratorUnionTests +{ + /// A second union, so two unions on one interface can be counted independently. + const string SecondNonBoxingUnion = """ + + namespace Testing + { + public record class Bird(string Name); + public record class Fish(string Name); + + [System.Runtime.CompilerServices.Union] + public struct Animal + { + readonly object? _value; + + public Animal(Bird value) => _value = value; + + public Animal(Fish value) => _value = value; + + public object? Value => _value; + + public bool HasValue => _value is not null; + + public bool TryGetValue(out Bird value) + { + if (_value is Bird bird) + { + value = bird; + return true; + } + + value = null!; + return false; + } + + public bool TryGetValue(out Fish value) + { + if (_value is Fish fish) + { + value = fish; + return true; + } + + value = null!; + return false; + } + } + } + + """; + + /// + /// The reporter's shape: [ActivitySource] and [Logger] on one interface, and a union-typed parameter + /// on the one method that targets both. connectionId is excluded from Activities, as in the report, which is + /// the shape that also proves a parameter-level [ExcludeTargets] does not move the accessor. + /// + const string ConnectorTelemetry = """ + + namespace Testing + { + public sealed record ProviderConnectionId(string Value); + + [ActivitySource("testing")] + [Logger] + public interface IConnectorTelemetry + { + [Activity] + System.Diagnostics.Activity? Retrieving(ProviderConnectionId connectionId); + + [Event(System.Diagnostics.ActivityStatusCode.Error)] + [Warning] + void InvalidConnection( + System.Diagnostics.Activity? activity, + [ExcludeTargets(Targets.Activities)] ProviderConnectionId connectionId, + Pet error); + } + } + + """; + + /// The reporter's second interface: one union, on the method that records an event and logs. + const string InventoryTelemetry = """ + + namespace Testing + { + public sealed record ReconciliationError(string Code); + + [Logger] + [ActivitySource("testing")] + public interface IRepositoryInventoryStoreTelemetry + { + [Activity] + [Info("Reconciling {RepositoryCount} repositories for {ProviderConnectionId}.")] + System.Diagnostics.Activity? ReconcilingRepositories(int repositoryCount, string providerConnectionId); + + [Warning] + [Event] + void ReconciliationRejected(System.Diagnostics.Activity? activity, ReconciliationError error); + } + } + + """; + + const string ActivityAndLogging = """ + + namespace Testing + { + [ActivitySource("testing")] + [Logger] + public interface IShape + { + [Activity] + [Info] + System.Diagnostics.Activity? DoWork([Tag] Pet pet); + } + } + + """; + + const string EventAndWarning = """ + + namespace Testing + { + [ActivitySource("testing")] + [Logger] + public interface IShape + { + [Event("shape.failed")] + [Warning] + void Failed(System.Diagnostics.Activity? activity, Pet error); + } + } + + """; + + const string ActivityLoggingAndMetrics = """ + + namespace Testing + { + [ActivitySource("testing")] + [Logger] + [Meter("testing-meter")] + public interface IShape + { + [Activity] + [Info] + [Counter("shape.counted")] + System.Diagnostics.Activity? DoWork([Tag] Pet pet, int value); + } + } + + """; + + const string ExcludedFromActivities = """ + + namespace Testing + { + [ActivitySource("testing")] + [Logger] + public interface IShape + { + [Event("shape.failed")] + [Warning] + void Failed(System.Diagnostics.Activity? activity, [ExcludeTargets(Targets.Activities)] Pet error); + } + } + + """; + + const string TwoMethodsSameUnion = """ + + namespace Testing + { + [ActivitySource("testing")] + [Logger] + public interface IShape + { + [Activity] + System.Diagnostics.Activity? DoWork([Tag] Pet pet); + + [Info] + void WorkLogged(Pet pet); + } + } + + """; + + const string TwoMethodsTwoUnions = """ + + namespace Testing + { + [ActivitySource("testing")] + [Logger] + public interface IShape + { + [Activity] + System.Diagnostics.Activity? DoWork([Tag] Pet pet); + + [Warning] + void WorkFailed(Animal animal); + } + } + + """; + + /// + /// A union that has no TryGetValue, so the accessor reads Value. A union declaration has this + /// shape; the test declarations are hand-written because the test legs predate the union keyword. + /// + const string ValueOnlyUnion = """ + + namespace Testing + { + [System.Runtime.CompilerServices.Union] + public struct Reason + { + readonly object? _value; + + public Reason(Cat value) => _value = value; + + public Reason(Dog value) => _value = value; + + public object? Value => _value; + } + } + + """; + + /// The six emission-matrix shapes, each expected to declare its union accessor the stated number of times. + public static IEnumerable<(string Name, string Source, int Expected)> UnionAccessorShapes => + [ + ("ActivityAndLogging", NonBoxingUnion + ActivityAndLogging, 1), + ("EventAndWarning", NonBoxingUnion + EventAndWarning, 1), + ("ActivityLoggingAndMetrics", NonBoxingUnion + ActivityLoggingAndMetrics, 1), + ("ExcludedFromActivities", NonBoxingUnion + ExcludedFromActivities, 1), + ("TwoMethodsSameUnion", NonBoxingUnion + TwoMethodsSameUnion, 1), + ("TwoMethodsTwoUnions", NonBoxingUnion + SecondNonBoxingUnion + TwoMethodsTwoUnions, 2), + ]; + + [Test] + public async Task Generate_GivenMultiTargetInterfaceWithUnionParameter_EmitsCompilableCode( + CancellationToken cancellationToken + ) + { + // Arrange + TelemetrySourceGeneratorTestOptions options = new() + { + // The diagnostics are asserted here rather than thrown, so a failure names the exact id and message. + ThrowOnGenerationException = false, + CompileToAssembly = true, + }; + + // Act + using var generationResult = await GenerateAsync( + NonBoxingUnion + ConnectorTelemetry + InventoryTelemetry, + options, + cancellationToken + ); + + // Assert + var compilationErrors = generationResult + .CompilationResult.Compilation.GetDiagnostics(cancellationToken) + .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .ToList(); + var emitErrors = generationResult + .CompilationResult.Diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .ToList(); + + var errors = compilationErrors + .Concat(emitErrors) + .Select(static diagnostic => + $"{diagnostic.Id}: {diagnostic.GetMessage(System.Globalization.CultureInfo.InvariantCulture)}" + ) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + await Assert + .That(string.Join(Environment.NewLine, errors)) + .IsEmpty() + .Because("one interface must declare a union accessor exactly once, whatever targets consume it"); + } + + [Test] + public async Task Generate_GivenMultiTargetInterfaceWithUnionParameter_DeclaresTheAccessorOnce( + CancellationToken cancellationToken + ) + { + // Arrange + // Act + using var generationResult = await GenerateAsync( + NonBoxingUnion + ConnectorTelemetry, + cancellationToken: cancellationToken + ); + + // Assert + generationResult.AssertNoCompilationErrors(); + + var counts = CountAccessorDeclarations(generationResult, "UnwrapUnion_Testing_Pet"); + + await Assert + .That(counts.Values.Sum()) + .IsEqualTo(1) + .Because($"the accessor must be declared once, but was declared as {Describe(counts)}"); + } + + [Test] + [MethodDataSource(nameof(UnionAccessorShapes))] + public async Task Generate_GivenUnionParameter_DeclaresTheAccessorTheExpectedNumberOfTimes( + string name, + string source, + int expected + ) + { + // Arrange + // Act + using var generationResult = await GenerateAsync(source); + + // Assert + generationResult.AssertNoCompilationErrors(); + + var counts = CountAccessorDeclarations(generationResult, "UnwrapUnion_Testing_"); + var total = counts.Values.Sum(); + + await Assert + .That(total) + .IsEqualTo(expected) + .Because($"{name} must declare {expected} accessor(s), but declared {Describe(counts)}"); + } + + [Test] + public async Task Generate_GivenMultiTargetInterfaceWithUnionParameter_UnwrapsTheValueAtEachCallSite( + CancellationToken cancellationToken + ) + { + // Arrange + // Act + using var generationResult = await GenerateAsync( + NonBoxingUnion + ConnectorTelemetry, + cancellationToken: cancellationToken + ); + + // Assert + generationResult.AssertNoCompilationErrors(); + + var activity = generationResult.GetSource("Activity.g.cs", HintNameMatchMode.Suffix); + var logging = generationResult.GetSource("Logging.g.cs", HintNameMatchMode.Suffix); + + await Assert + .That(activity) + .Contains("tagsCollectionInvalidConnection.Add(\"error\", UnwrapUnion_Testing_Pet(error))") + .Because("the activity event records the union's case value, not the union struct"); + await Assert + .That(logging) + .Contains("UnwrapUnion_Testing_Pet(error)") + .Because("the log entry carries the union's case value"); + await Assert + .That(activity) + .Contains("TryGetValue(out global::Testing.Cat caseValue0)") + .Because("the owning target declares the accessor, reading the union through its non-boxing overloads"); + } + + [Test] + public async Task Generate_GivenMultiTargetInterfaceWithValueOnlyUnion_ReadsTheValueProperty( + CancellationToken cancellationToken + ) + { + // Arrange: the union on the method that records an event and logs, so both targets see it. + var source = + NonBoxingUnion + + ValueOnlyUnion + + """ + + namespace Testing + { + [ActivitySource("testing")] + [Logger] + public interface IShape + { + [Event("shape.failed")] + [Warning] + void Failed(System.Diagnostics.Activity? activity, Reason reason); + } + } + + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + generationResult.AssertNoCompilationErrors(); + + var declaring = generationResult.GetSource("Activity.g.cs", HintNameMatchMode.Suffix); + + await Assert.That(declaring).Contains("return value.Value;"); + await Assert + .That(CountAccessorDeclarations(generationResult, "UnwrapUnion_Testing_Reason").Values.Sum()) + .IsEqualTo(1); + } + + [Test] + public async Task Generate_GivenLoggingAndMetricsInterfaceWithUnionOnTheMetricMethod_DeclaresTheAccessorOnce( + CancellationToken cancellationToken + ) + { + // Arrange: the canonical target (Logging) does not itself see the metric method's parameters, so the accessor + // list has to come from the interface rather than from the owning target's own method records. + const string source = + NonBoxingUnion + + """ + + namespace Testing + { + [Logger] + [Meter("testing-meter")] + public interface IShape + { + [Info] + void WorkLogged(string message); + + [Counter("shape.counted")] + void WorkCounted(int value, [Tag] Pet pet); + } + } + + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + generationResult.AssertNoCompilationErrors(); + + var counts = CountAccessorDeclarations(generationResult, "UnwrapUnion_Testing_Pet"); + + await Assert + .That(counts.Values.Sum()) + .IsEqualTo(1) + .Because($"the accessor must be declared once, but was declared as {Describe(counts)}"); + await Assert + .That(string.Join(", ", counts.Keys)) + .EndsWith("Logging.g.cs") + .Because("Logging is the canonical target when the interface registers no Activities"); + } + + [Test] + public async Task Generate_GivenSingleTargetInterfaceWithUnionParameter_KeepsTheAccessorInTheTargetFile( + CancellationToken cancellationToken + ) + { + // Arrange: a single-target interface has one generated file, so its accessor placement and shape are unchanged + // by the multi-target fix. This pins both. + const string source = + NonBoxingUnion + + """ + + namespace Testing + { + [Logger] + public interface ITestLogger + { + [Info("Pet {Pet}")] + void LogPet(Pet pet); + } + } + + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + generationResult.AssertNoCompilationErrors(); + + var counts = CountAccessorDeclarations(generationResult, "UnwrapUnion_Testing_Pet"); + + await Assert.That(counts.Values.Sum()).IsEqualTo(1); + await Assert.That(string.Join(", ", counts.Keys)).EndsWith("Logging.g.cs"); + + var logging = generationResult.GetSource("Logging.g.cs", HintNameMatchMode.Suffix); + + await Assert + .That(logging) + .Contains("private static object? UnwrapUnion_Testing_Pet(global::Testing.Pet value)"); + await Assert.That(logging).Contains("[global::System.Runtime.CompilerServices.MethodImpl("); + await Assert.That(logging).Contains("return null;"); + } + + [Test] + public async Task Generate_GivenMultiTargetInterfaceWithUnionParameter_ReportsNoAnalyzerDiagnostic( + CancellationToken cancellationToken + ) + { + // Arrange + // Act + using var generationResult = await GenerateAsync( + NonBoxingUnion + ConnectorTelemetry + InventoryTelemetry, + cancellationToken: cancellationToken + ); + + // Assert + var diagnostics = (generationResult.AnalyzerResult?.Diagnostics ?? []) + .Where(static diagnostic => diagnostic.Id.StartsWith("TSG", StringComparison.Ordinal)) + .Select(static diagnostic => diagnostic.Id) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + await Assert + .That(string.Join(", ", diagnostics)) + .IsEmpty() + .Because("the union accessor fix must not need a new consumer-facing diagnostic"); + } + + /// + /// Counts the declarations of an accessor across every generated source, keyed by hint name. Call sites are not + /// counted, so a helper that is referenced from another target's file does not inflate the total. + /// + static Dictionary CountAccessorDeclarations(DriverRunResult result, string prefix) + { + Dictionary counts = [with(StringComparer.Ordinal)]; + + foreach (var source in result.DriverResult.Results.SelectMany(static generator => generator.GeneratedSources)) + { + var root = CSharpSyntaxTree.ParseText(source.SourceText.ToString()).GetRoot(); + var count = root.DescendantNodes() + .OfType() + .Count(method => method.Identifier.ValueText.StartsWith(prefix, StringComparison.Ordinal)); + + if (count > 0) + counts[source.HintName] = count; + } + + return counts; + } + + static string Describe(Dictionary counts) => + counts.Count == 0 + ? "no accessor at all" + : string.Join(", ", counts.Select(static pair => $"{pair.Key}={pair.Value}")); +} From 40efa54a5b13ed1c92f8847c74b454b7841ba5a9 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sun, 15 Nov 2026 00:12:36 +0000 Subject: [PATCH 03/12] test(unions): prove the union accessor plan is cache-stable The planner computes the accessor list from the interface rather than from the owning target's own method records, so the value it puts on the target model has to stay value-equatable or every edit anywhere would re-run generation. Adds two incremental tests over a multi-target interface with a union both families consume: - unchanged sources: every framework-named stage output is Cached or Unchanged on the second run, which is what proves the new EquatableArray member compares equal; - an unrelated type added: the stages stay cached and the generated sources are byte-identical, which proves the interface-wide scan does not widen invalidation. The union source is hand-written with plain class cases so the net48 leg compiles it too, and the tests therefore run on all three legs. --- .../TelemetrySourceGeneratorCachingTests.cs | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorCachingTests.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorCachingTests.cs index 24313ab0..0894099d 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorCachingTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorCachingTests.cs @@ -42,6 +42,85 @@ namespace Testing; public class Unrelated { public int Value { get; set; } } """; + /// + /// A hand-written C# 15 union, so the union shape runs on every test leg: the harness pins C# 14, which has no + /// union keyword and no UnionAttribute in the box, and the case types are plain classes rather than + /// records so the net48 leg can compile them too. + /// + const string UnionSource = """ + using System; + + namespace System.Runtime.CompilerServices + { + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] + public sealed class UnionAttribute : Attribute { } + } + + namespace Testing + { + public sealed class Ordered { } + public sealed class Rejected { } + + [System.Runtime.CompilerServices.Union] + public struct Outcome + { + readonly object? _value; + + public Outcome(Ordered value) => _value = value; + + public Outcome(Rejected value) => _value = value; + + public object? Value => _value; + + public bool HasValue => _value is not null; + + public bool TryGetValue(out Ordered value) + { + if (_value is Ordered ordered) + { + value = ordered; + return true; + } + + value = null!; + return false; + } + + public bool TryGetValue(out Rejected value) + { + if (_value is Rejected rejected) + { + value = rejected; + return true; + } + + value = null!; + return false; + } + } + } + """; + + /// + /// The shape the accessor plan exists for: two target families and one union they both consume. The plan is built + /// from the interface rather than from the owning target's own method records, which is the part that has to stay + /// value-equatable for the pipeline to cache. + /// + const string MultiTargetUnionInterface = """ + using Purview.Telemetry; + + namespace Testing; + + [ActivitySource("testing-activity-source")] + [Logger] + public interface IMultiUnionTelemetry + { + [Event("union.failed")] + [Warning] + void Failed(System.Diagnostics.Activity? activity, Outcome outcome); + } + """; + static readonly TelemetrySourceGeneratorTestOptions Options = new(); /// @@ -230,4 +309,69 @@ await Assert .Because($"editing the logger must change the logger output '{hintName}'"); } } + + /// + /// The union accessor plan is computed from the interface rather than from the owning target's own method records. + /// That must stay a value-equatable model: a second run over unchanged sources re-runs nothing. + /// + [Test] + public async Task Generate_UnchangedMultiTargetUnionInterface_AccessorPlanCaches( + CancellationToken cancellationToken + ) + { + var result = await GenerateIncrementalAsync( + [UnionSource, MultiTargetUnionInterface], + Options, + cancellationToken + ); + + await Assert + .That(GetFrameworkReasons(result.Runs[0]).All(static reason => reason == IncrementalStepRunReason.New)) + .IsTrue() + .Because("the first run must produce every output"); + await Assert + .That( + GetFrameworkReasons(result.Runs[1]) + .All(static reason => + reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged + ) + ) + .IsTrue() + .Because("the accessor plan must compare equal across runs, or any unrelated edit re-runs generation"); + } + + /// + /// Scanning the whole interface for union parameters must not widen invalidation: an unrelated edit leaves the + /// union interface's stages cached and its generated accessor byte-identical. + /// + [Test] + public async Task Generate_UnrelatedChangeWithMultiTargetUnionInterface_OutputStaysCached( + CancellationToken cancellationToken + ) + { + var inputs = new[] + { + new IncrementalRunInput([UnionSource, MultiTargetUnionInterface], []), + new IncrementalRunInput([UnionSource, MultiTargetUnionInterface, UnrelatedType], []), + }; + var result = await GenerateIncrementalAsync(inputs, Options, cancellationToken); + + var reasons = GetFrameworkReasons(result.Runs[1]); + await Assert.That(reasons).IsNotEmpty(); + await Assert + .That( + reasons.All(static reason => + reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged + ) + ) + .IsTrue() + .Because("an unrelated edit must not invalidate the union interface's generated output"); + + var first = GetGeneratedSources(result.Runs[0].RunResult); + var second = GetGeneratedSources(result.Runs[1].RunResult); + + await Assert.That(second.Count).IsEqualTo(first.Count); + foreach (var source in first) + await Assert.That(second[source.Key]).IsEqualTo(source.Value); + } } From fc8d8d908dc9c4378d97d66ef616cd670a962b25 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sun, 15 Nov 2026 00:34:06 +0000 Subject: [PATCH 04/12] build(just): adopt the shared just modules in .build/just The root Justfile now imports one barrel, .build/just/index.just, and holds only what belongs to this repository: its variables, its own recipes and three deliberate overrides (test, pack, pipeline-pack-validate). Everything a shared module provides was deleted, so a leftover copy cannot silently replace the module's recipe. .lint -> lint-check and ensure-pipeline-tool -> ensure-tools come from the modules. bump is new: it rewrites package.json and then runs update-version through _after-bump, so a release needs only the bump. The modules are copied verbatim from the purview-buildsdk-justfile skill and are byte-identical to its assets. --- .build/just/bump-version.ts | 62 ++++ .build/just/dotnet.just | 53 ++++ .build/just/env.just | 56 ++++ .build/just/index.just | 41 +++ .build/just/purview-build-nuget.just | 30 ++ .build/just/purview-build.just | 24 ++ .build/just/skills.just | 52 ++++ .build/just/utilities.just | 44 +++ .build/just/version.just | 28 ++ AGENTS.md | 31 +- Justfile | 264 +++++++----------- docs/wiki/{ => contributing}/Contributing.md | 0 docs/wiki/{ => contributing}/Release-Flow.md | 0 docs/wiki/{ => guide}/Installation.md | 0 docs/wiki/{ => guide}/Sample-Application.md | 0 docs/wiki/{ => guide}/Testing.md | 0 .../Migration-From-Activities.md | 0 .../{ => migration}/Migration-From-ILogger.md | 0 docs/wiki/{ => reference}/Breaking-Changes.md | 0 docs/wiki/{ => reference}/Diagnostics.md | 0 docs/wiki/{ => reference}/FAQ.md | 0 docs/wiki/{ => reference}/Generated-Output.md | 0 docs/wiki/{ => reference}/Performance.md | 0 docs/wiki/{ => reference}/Platform-Support.md | 0 docs/wiki/{ => reference}/Refactorings.md | 0 docs/wiki/{ => telemetry}/Activities.md | 0 .../{ => telemetry}/Logging-Generation-v1.md | 0 .../{ => telemetry}/Logging-Generation-v2.md | 0 docs/wiki/{ => telemetry}/Logging.md | 0 docs/wiki/{ => telemetry}/Metrics.md | 0 docs/wiki/{ => telemetry}/Multi-Targeting.md | 0 docs/wiki/{ => telemetry}/Tags-and-Baggage.md | 0 32 files changed, 511 insertions(+), 174 deletions(-) create mode 100644 .build/just/bump-version.ts create mode 100644 .build/just/dotnet.just create mode 100644 .build/just/env.just create mode 100644 .build/just/index.just create mode 100644 .build/just/purview-build-nuget.just create mode 100644 .build/just/purview-build.just create mode 100644 .build/just/skills.just create mode 100644 .build/just/utilities.just create mode 100644 .build/just/version.just rename docs/wiki/{ => contributing}/Contributing.md (100%) rename docs/wiki/{ => contributing}/Release-Flow.md (100%) rename docs/wiki/{ => guide}/Installation.md (100%) rename docs/wiki/{ => guide}/Sample-Application.md (100%) rename docs/wiki/{ => guide}/Testing.md (100%) rename docs/wiki/{ => migration}/Migration-From-Activities.md (100%) rename docs/wiki/{ => migration}/Migration-From-ILogger.md (100%) rename docs/wiki/{ => reference}/Breaking-Changes.md (100%) rename docs/wiki/{ => reference}/Diagnostics.md (100%) rename docs/wiki/{ => reference}/FAQ.md (100%) rename docs/wiki/{ => reference}/Generated-Output.md (100%) rename docs/wiki/{ => reference}/Performance.md (100%) rename docs/wiki/{ => reference}/Platform-Support.md (100%) rename docs/wiki/{ => reference}/Refactorings.md (100%) rename docs/wiki/{ => telemetry}/Activities.md (100%) rename docs/wiki/{ => telemetry}/Logging-Generation-v1.md (100%) rename docs/wiki/{ => telemetry}/Logging-Generation-v2.md (100%) rename docs/wiki/{ => telemetry}/Logging.md (100%) rename docs/wiki/{ => telemetry}/Metrics.md (100%) rename docs/wiki/{ => telemetry}/Multi-Targeting.md (100%) rename docs/wiki/{ => telemetry}/Tags-and-Baggage.md (100%) diff --git a/.build/just/bump-version.ts b/.build/just/bump-version.ts new file mode 100644 index 00000000..8cb38ffb --- /dev/null +++ b/.build/just/bump-version.ts @@ -0,0 +1,62 @@ +// Bumps one part of the semantic version in package.json, for `just bump `. +// +// The parts after the one bumped are reset to 0, and a pre-release or build suffix is dropped: +// +// 2.0.1 major -> 3.0.0 minor -> 2.1.0 patch -> 2.0.2 +// 2.1.0-rc.1 patch -> 2.1.1 +// +// Only the "version" value is rewritten, in place, so the file keeps its own indentation, key order +// and line endings - re-serialising the JSON would reformat it. +// +// bun bump-version.ts [package.json] [--dry-run] + +import { readFileSync, writeFileSync } from "node:fs"; + +const parts = ["major", "minor", "patch"] as const; +type Part = (typeof parts)[number]; + +const args = process.argv.slice(2); +const dryRun = args.includes("--dry-run"); +const [part, file = "package.json"] = args.filter((arg) => arg !== "--dry-run"); + +if (!parts.includes(part as Part)) { + console.error(`Expected one of ${parts.join(", ")}, not '${part ?? ""}'.`); + process.exit(1); +} + +const text = readFileSync(file, "utf8"); +const versionPattern = /("version"\s*:\s*")([^"]*)(")/; +const match = versionPattern.exec(text); +if (!match) { + console.error(`No "version" field in ${file}.`); + process.exit(1); +} + +const current = match[2]; +const semver = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(current); +if (!semver) { + console.error(`'${current}' in ${file} is not a major.minor.patch version.`); + process.exit(1); +} + +let [major, minor, patch] = semver.slice(1, 4).map(Number); +switch (part as Part) { + case "major": + [major, minor, patch] = [major + 1, 0, 0]; + break; + case "minor": + [minor, patch] = [minor + 1, 0]; + break; + case "patch": + patch += 1; + break; +} + +const next = `${major}.${minor}.${patch}`; +if (dryRun) { + console.log(` ${file}: ${current} -> ${next} (dry run, nothing written)`); + process.exit(0); +} + +writeFileSync(file, text.replace(versionPattern, `$1${next}$3`)); +console.log(` ${file}: ${current} -> ${next}`); diff --git a/.build/just/dotnet.just b/.build/just/dotnet.just new file mode 100644 index 00000000..9507263f --- /dev/null +++ b/.build/just/dotnet.just @@ -0,0 +1,53 @@ +# .NET recipes: tools, restore, build, clean, test and pack, all against one solution. +# +# Override these in the root Justfile. `solution_file` is the one nearly every repository sets; "." +# lets dotnet find the single solution or project in the repository root. + +solution_file := "." +build_configuration := "Debug" +artifacts_folder := "./artifacts" + +# Every test. A TUnit/MTP tree-node filter: /assembly/namespace/class/test. +test_filter := "/*/*/*/*/" + +# Restore the repository's .NET local tools (.config/dotnet-tools.json) +[group('Build and Test')] +ensure-tools: + dotnet tool restore + +# Restore NuGet packages for the solution +[group('Build and Test')] +restore *args: + echo "==> Restoring {{ BLUE }}{{ solution_file }}{{ NORMAL }}" + dotnet restore "{{ solution_file }}" {{ args }} + +# Build the solution with build_configuration (default: Debug) +[group('Build and Test')] +build *args: + echo "==> Building {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ GREEN }}{{ current_version }}{{ NORMAL }}) with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}" + dotnet build "{{ solution_file }}" --configuration "{{ build_configuration }}" {{ args }} + +# Clean the solution with build_configuration (default: Debug) +[group('Build and Test')] +clean *args: + echo "==> Cleaning {{ BLUE }}{{ solution_file }}{{ NORMAL }} with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}" + dotnet clean "{{ solution_file }}" --configuration "{{ build_configuration }}" {{ args }} + +# Run the tests matching a tree-node filter (default: every test) +# +# just test "/*/*/*/*[Category=Unit]" +# just test "/*/*/ProjectHarnessTests/*" +# +# Exit code 8 - "no test ran" - is ignored, so a filter that matches nothing in one test project does +# not fail the run for the others. +[doc('Run the tests matching a tree-node filter (default: every test)')] +[group('Build and Test')] +test filter=test_filter *args: + echo "==> Testing {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ YELLOW }}{{ build_configuration }}{{ NORMAL }}) with filter {{ GREEN }}{{ filter }}{{ NORMAL }}" + dotnet test --project "{{ solution_file }}" --configuration "{{ build_configuration }}" --treenode-filter "{{ filter }}" --ignore-exit-code 8 {{ args }} + +# Pack every packable project into artifacts_folder (default: ./artifacts) +[group('Build and Test')] +pack artifact_folder=artifacts_folder *args: + echo "==> Packing {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ GREEN }}{{ current_version }}{{ NORMAL }}) to {{ YELLOW }}{{ artifact_folder }}{{ NORMAL }}" + dotnet pack "{{ solution_file }}" --configuration "{{ build_configuration }}" --output "{{ artifact_folder }}" {{ args }} diff --git a/.build/just/env.just b/.build/just/env.just new file mode 100644 index 00000000..6156f34b --- /dev/null +++ b/.build/just/env.just @@ -0,0 +1,56 @@ +# The environment every recipe runs in: telemetry opt-outs, and the .NET CLI speed-ups. +# +# `export` hands each value to every process a recipe starts - dotnet, bun, pwsh, git. Each one is a +# default: `env(NAME, value)` keeps whatever the caller's environment already says, so a developer +# who has opted back in, or a CI job that sets its own value, is never overridden. + +# -- Telemetry and tracking -------------------------------------------------------------------------- + +# The .NET CLI, MSBuild and Aspire. +export DOTNET_CLI_TELEMETRY_OPTOUT := env("DOTNET_CLI_TELEMETRY_OPTOUT", "1") +export MSBUILD_TELEMETRY_OPTOUT := env("MSBUILD_TELEMETRY_OPTOUT", "1") +export ASPIRE_CLI_TELEMETRY_OPTOUT := env("ASPIRE_CLI_TELEMETRY_OPTOUT", "true") +export ASPIRE_DASHBOARD_TELEMETRY_OPTOUT := env("ASPIRE_DASHBOARD_TELEMETRY_OPTOUT", "true") + +# The Azure SDKs and the Azure MCP server. +export AZURE_CORE_COLLECT_TELEMETRY := env("AZURE_CORE_COLLECT_TELEMETRY", "0") +export AZURE_MCP_COLLECT_TELEMETRY := env("AZURE_MCP_COLLECT_TELEMETRY", "0") + +# PowerShell, for scripts a recipe runs through pwsh, and Git Credential Manager. +export POWERSHELL_TELEMETRY_OPTOUT := env("POWERSHELL_TELEMETRY_OPTOUT", "1") +export GCM_TELEMETRY_OPTOUT := env("GCM_TELEMETRY_OPTOUT", "1") + +# JavaScript tooling: Next.js, Astro, Yarn, and the funding banners printed by postinstall scripts. +export NEXT_TELEMETRY_DISABLED := env("NEXT_TELEMETRY_DISABLED", "1") +export ASTRO_TELEMETRY_DISABLED := env("ASTRO_TELEMETRY_DISABLED", "1") +export YARN_ENABLE_TELEMETRY := env("YARN_ENABLE_TELEMETRY", "0") +export DISABLE_OPENCOLLECTIVE := env("DISABLE_OPENCOLLECTIVE", "true") + +# Claude Code, when a recipe launches it. +export CLAUDE_CLI_TELEMETRY_OPTOUT := env("CLAUDE_CLI_TELEMETRY_OPTOUT", "1") +export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC := env("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") +export DISABLE_ERROR_REPORTING := env("DISABLE_ERROR_REPORTING", "1") +export DISABLE_TELEMETRY := env("DISABLE_TELEMETRY", "1") + +# The cross-tool conventions, honoured by Bun and many other CLIs. +export DO_NOT_TRACK := env("DO_NOT_TRACK", "1") +export TELEMETRY_DISABLED := env("TELEMETRY_DISABLED", "1") + +# -- .NET CLI speed and noise ------------------------------------------------------------------------ + +# No welcome banner or first-run notice. +export DOTNET_NOLOGO := env("DOTNET_NOLOGO", "1") + +# The NativeAOT CLI host (.NET 11): near-instant `dotnet --version`, `--help` and tool launches, with +# everything else falling back to the managed CLI. On by default; opt out with DOTNET_CLI_ENABLEAOT=false. +export DOTNET_CLI_ENABLEAOT := env("DOTNET_CLI_ENABLEAOT", "true") + +# The MSBuild server (DOTNET_CLI_USE_MSBUILD_SERVER) is deliberately left to the SDK's own default. +# Forcing it on makes every `dotnet build` share one long-lived MSBuild process, and its NuGet SDK +# resolver keeps the settings it first read: a build under a different nuget.config - a test +# harness's throwaway consumer, a second repository - then resolves SDKs with the wrong sources and +# fallback folders, and fails with MSB4242. Set it yourself where every build shares one NuGet +# configuration; `just scrub` shuts the server down. + +# Multithreaded MSBuild (-mt) is deliberately not set here. Purview.BuildSdk bootstraps it into the +# repository's Directory.Build.rsp, so a plain `dotnet build` and an IDE build get it as well. diff --git a/.build/just/index.just b/.build/just/index.just new file mode 100644 index 00000000..1d73bbe6 --- /dev/null +++ b/.build/just/index.just @@ -0,0 +1,41 @@ +# The barrel for the shared just modules. The root Justfile imports this file, and nothing else: +# +# set allow-duplicate-variables +# set allow-duplicate-recipes +# +# import ".build/just/index.just" +# +# Every module gives its variables a default. The root Justfile overrides one by defining the same +# name - `solution_file := "src/Acme.slnx"` - and overrides a no-op hook such as `_after-bump` the +# same way. Both settings are what let a root definition win; without them a duplicate is an error. +# +# The required modules are imported unconditionally. The optional ones use `import?`, so a module is +# switched on by copying its file into this folder and off by deleting it: this file never changes. +# +# These files are copied verbatim from the purview-buildsdk-justfile skill. Change a recipe there, +# not here, and put anything specific to one repository in its root Justfile. + +# Telemetry opt-outs and the .NET CLI speed-ups, exported to every recipe. +import "env.just" + +# version, bump +import "version.just" + +# ensure-tools, restore, build, clean, test, pack +import "dotnet.just" + +# lint-check, lint-fix, vs, scrub +import "utilities.just" + +# pipeline-pr, pipeline-build, pipeline-tests - local runs of the Purview.Build pipeline +import "purview-build.just" + +# skills-* - the Purview.BuildSdk agent-skill targets +import "skills.just" + +# Optional. pipeline-pack-validate, pipeline-local-release, pipeline-release - for a repository that +# packs NuGet packages. +import? "purview-build-nuget.just" + +# Optional. docs, docs-install, docs-check - for a pm-docs-site project site (purview-dev/pm-skills). +import? "docs.just" diff --git a/.build/just/purview-build-nuget.just b/.build/just/purview-build-nuget.just new file mode 100644 index 00000000..21548760 --- /dev/null +++ b/.build/just/purview-build-nuget.just @@ -0,0 +1,30 @@ +# Optional: Purview.Build pipeline runs that pack, validate and publish NuGet packages. Copy this file +# into the module folder only when the repository packs packages; the barrel imports it if present. +# +# Pack validation reads PackValidation in .config/purview-build.json. Its RequiredContent lists every +# file each package must contain, and a packed file it does not list fails the run - so a new file +# under a package's Sdk/, build/ or buildTransitive/ needs a RequiredContent entry too. + +# Run the pipeline through pack and pack validation (restore, build, lint, tests, pack, validate), publishing nothing +[group('Pipeline')] +pipeline-pack-validate *args: ensure-tools + echo "==> Running the pack and validate pipeline" + {{ pipeline_tool }} --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} + +# Run the release pipeline, publishing to a local NuGet feed (restore, build, lint, tests, pack, validate, publish) +# +# `just` runs recipes through sh, which strips backslashes from unquoted arguments. Set the +# LOCAL_NUGET_FEED_PATH environment variable, or use forward slashes: +# +# just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ +[doc('Run the release pipeline, publishing to a local NuGet feed (restore, build, lint, tests, pack, validate, publish)')] +[group('Pipeline')] +pipeline-local-release *args: ensure-tools + echo "==> Running the local release pipeline" + {{ pipeline_tool }} --Release:Mode=LocalNuGet {{ args }} + +# Run the release pipeline, publishing to NuGet (restore, build, lint, tests, pack, validate, publish, GitHub release) +[group('Pipeline')] +pipeline-release *args: ensure-tools + echo "==> Running the release pipeline" + {{ pipeline_tool }} --Release:Mode=NuGet {{ args }} diff --git a/.build/just/purview-build.just b/.build/just/purview-build.just new file mode 100644 index 00000000..244e44cd --- /dev/null +++ b/.build/just/purview-build.just @@ -0,0 +1,24 @@ +# Local runs of the Purview.Build pipeline - the same `dotnet purview-build` tool, and the same +# .config/purview-build.json, that CI runs. Every Build stage - tests, lint, pack and pack validation - +# runs unless that file or an argument turns it off. Anything after the recipe name is passed to the +# tool, so a setting can be overridden for one run: `just pipeline-pr --Build:TestFilter="/*/*/*/*[Category=Unit]"`. + +pipeline_tool := "dotnet purview-build" + +# Run the PR pipeline (restore, build, lint, tests, pack, validate), as configured +[group('Pipeline')] +pipeline-pr *args: ensure-tools + echo "==> Running the PR pipeline" + {{ pipeline_tool }} {{ args }} + +# Run the build pipeline (restore, build, lint, pack, validate), without tests +[group('Pipeline')] +pipeline-build *args: ensure-tools + echo "==> Running the build pipeline" + {{ pipeline_tool }} --Build:RunTests=false --Release:Mode=None {{ args }} + +# Run the pipeline with tests forced on and release off (restore, build, lint, tests, pack, validate) +[group('Pipeline')] +pipeline-tests *args: ensure-tools + echo "==> Running the tests pipeline" + {{ pipeline_tool }} --Build:RunTests=true --Release:Mode=None {{ args }} diff --git a/.build/just/skills.just b/.build/just/skills.just new file mode 100644 index 00000000..0d339023 --- /dev/null +++ b/.build/just/skills.just @@ -0,0 +1,52 @@ +# The Purview.BuildSdk agent-skill targets. Each recipe names one target; the behaviour lives there. +# +# The targets are a thin wrapper over the standard skills CLI (`bun x skills` by default), run from the +# repository root. Every skill target runs once for the whole solution, however many projects it holds, +# so the recipes name the solution, not a hand-picked project. +# +# Every recipe here is run by a person, on purpose. Never make one a dependency of build, test or +# default, or call skills-add, -install, -update or -fix from a git hook: they change the repository, +# and the SDK refuses to run them inside a build (PRSKL0009). skills-check is read-only. +# +# The runner and agents are MSBuild properties (PurviewSkillsRunner, PurviewSkillsAgents), set once in +# Directory.Build.props. Pass a one-off through *args; `dotnet msbuild -p:` splits on `,` and `;`, so +# escape the separator: `just skills-update -p:PurviewSkillsAgents=claude-code%2Ccodex`. + +skills_project := solution_file + +# Install skills from another repository through the skills CLI (append '#' to pin it) +# +# just skills-add purview-dev/engineering-playbook +[doc("Install skills from another repository through the skills CLI (append '#' to pin it)")] +[group('Agent Skills')] +skills-add source *args: + echo "==> Installing skills from {{ BLUE }}{{ source }}{{ NORMAL }}" + dotnet msbuild "{{ skills_project }}" -t:InstallPurviewSkills -p:PurviewSkillsSource={{ source }} -v:n {{ args }} + +# Restore every skill skills-lock.json records - how a fresh clone gets the repository's skills back +[group('Agent Skills')] +skills-install *args: + dotnet msbuild "{{ skills_project }}" -t:InstallPurviewSkills -v:n {{ args }} + +# Update the agent skills by re-adding every unique source in skills-lock.json +# +# Each source is re-added at its latest with every skill it has, which refreshes the installed skills +# and adds any a source has gained; `skills update` is never run. FixPurviewSkills then removes links +# from agent directories PurviewSkillsAgents does not select, and does nothing when it is unset. +# The -t: value is quoted because sh would otherwise end the command at the `;`. +# +# just skills-update -p:PurviewSkillsDryRun=true +[doc('Update the agent skills by re-adding every unique source in skills-lock.json')] +[group('Agent Skills')] +skills-update *args: + dotnet msbuild "{{ skills_project }}" "-t:UpdatePurviewSkills;FixPurviewSkills" -v:n {{ args }} + +# Check that only the skill directories of the agents in PurviewSkillsAgents exist +[group('Agent Skills')] +skills-check *args: + dotnet msbuild "{{ skills_project }}" -t:CheckPurviewSkills -v:n {{ args }} + +# Remove the skills CLI's links from agent directories PurviewSkillsAgents does not select +[group('Agent Skills')] +skills-fix *args: + dotnet msbuild "{{ skills_project }}" -t:FixPurviewSkills -v:n {{ args }} diff --git a/.build/just/utilities.just b/.build/just/utilities.just new file mode 100644 index 00000000..d813497b --- /dev/null +++ b/.build/just/utilities.just @@ -0,0 +1,44 @@ +# Formatting, opening the solution, and scrubbing the build output. + +csharpier := "dotnet csharpier" + +# Check formatting with CSharpier +[group('Utilities')] +lint-check: + {{ csharpier }} check . + +# Format the code with CSharpier +[group('Utilities')] +lint-fix: + {{ csharpier }} format . + +# Open the solution in Visual Studio, or whichever application is registered for it +[group('Utilities')] +[windows] +vs: + start "" "{{ solution_file }}" + +# Open the solution in whichever application is registered for it +[group('Utilities')] +[macos] +vs: + open "{{ solution_file }}" + +# Open the solution in whichever application is registered for it +[group('Utilities')] +[linux] +vs: + xdg-open "{{ solution_file }}" + +# Delete every bin/, obj/ and artifacts_folder, stop the build servers, and restore from scratch +# +# The build servers go first: the MSBuild server and the compiler server hold files open under obj/, +# which on Windows stops them being deleted. node_modules/ and .git/ are never searched. +[doc('Delete every bin/, obj/ and artifacts_folder, stop the build servers, and restore from scratch')] +[group('Utilities')] +scrub: + echo "==> Scrubbing build output and stopping the build servers" + dotnet build-server shutdown + find . \( -name node_modules -o -name .git \) -prune -o -type d \( -name bin -o -name obj \) -prune -exec rm -rf {} + + rm -rf "{{ artifacts_folder }}" + just restore --force-evaluate diff --git a/.build/just/version.just b/.build/just/version.just new file mode 100644 index 00000000..09dbb375 --- /dev/null +++ b/.build/just/version.just @@ -0,0 +1,28 @@ +# The package version, from package.json: the single source of truth Purview.BuildSdk versions every +# project and package from. + +version_file := "package.json" + +# Read by just itself rather than by spawning bun, so `just --list` needs no Node tooling. +current_version := if path_exists(version_file) == "true" { replace_regex(read(version_file), '(?s).*?"version"\s*:\s*"([^"]*)".*', '$1') } else { "0.0.0" } + +# Display the current version, from package.json +[group('Version')] +version: + echo "==> Current version: {{ GREEN }}{{ current_version }}{{ NORMAL }} (from {{ version_file }}, stamped into every build by Purview.BuildSdk)" + +# Bump one part of the version in package.json - major, minor or patch - resetting the parts after it +# +# just bump minor # 2.0.1 -> 2.1.0 +# just bump patch --dry-run # print the new version, write nothing +# +# A pre-release suffix is dropped. Requires Bun. Afterwards it runs `_after-bump`, which does nothing +# unless the root Justfile overrides it to refresh whatever else pins the version. +[doc('Bump one part of the version in package.json - major, minor or patch - resetting the parts after it')] +[arg("part", pattern="major|minor|patch")] +[group('Version')] +bump part *args: && _after-bump + bun "{{ source_directory() / 'bump-version.ts' }}" "{{ part }}" "{{ version_file }}" {{ args }} + +# Runs after a successful `bump`. Override it in the root Justfile, e.g. `_after-bump: version-sync`. +_after-bump: diff --git a/AGENTS.md b/AGENTS.md index 0140a364..386442d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ All `just` recipes read the repository's [`Justfile`](Justfile). Configuration d | Command | Purpose | | --- | --- | | `just format` | Runs `dotnet format` over `./src/`. | -| `just lint` | Runs `dotnet csharpier check .` (no writes). | +| `just lint-check` | Runs `dotnet csharpier check .` (no writes). | | `just lint-fix` | Runs `dotnet csharpier format .` (writes). | `csharpier check` also runs in the pre-commit hook (`.config/lefthook.yml`) alongside a commitlint @@ -64,6 +64,7 @@ conventional-commits check. | Command | Purpose | | --- | --- | | `just version` | Prints the current version from `package.json`. | +| `just bump major\|minor\|patch` | Bumps that part of the version in `package.json`, then runs `update-version` through `_after-bump`. | | `just update-version` | Runs `.build/update-version.ts` to sync the version into docs/samples. | | `just pack` | Updates the version then packs the NuGet package into `artifacts/`. | @@ -132,7 +133,7 @@ samples/ benchmarks/ └── Purview.Telemetry.Benchmarks/ # BenchmarkDotNet project (net48/net10.0/net11.0) -.build/ # TS scripts (build-pack.ts, update-version.ts) +.build/ # TS scripts (build-pack.ts, update-version.ts) and just/ modules .agents/skills/ # Installed + developer skills, committed (see "Installed skills") skills-lock.json # The skills CLI lockfile for .agents/skills/, committed skills/ # Authored skills shipped by this package (see "Authoring skills") @@ -141,7 +142,10 @@ skills/ # Authored skills shipped by this pa scripts/ # release-gate scripts (see "Quality gates"), legacy setup-release.* └── packed-consumers/ # Native AOT, Blazor WebAssembly and Blazor Server consumers of the .nupkg docs/release-process.md # Release process documentation -docs/wiki/ # User documentation (Platform-Support.md: TFM/hosting matrix, .NET 11) +docs/wiki/ # User documentation (purview.dev docs), one topic folder per area +├── guide/ telemetry/ reference/ # migration/ contributing/ - _Sidebar.md is the navigation +├── diagrams/ # LikeC4 models (.c4), each paired with a Mermaid fence in the page +└── _redirects.yml # old URL -> new URL for every moved page ``` ## Build and validation workflow @@ -151,7 +155,7 @@ After making changes to the source generator: 1. `just build` — build the main solution. 2. `just test` — run the integration tests. 3. `just build-s && just test-s` — build and test the sample application end to end. -4. `just lint` / `just format` — ensure C# formatting compliance before committing. +4. `just lint-check` / `just format` — ensure C# formatting compliance before committing. The sample projects enable `EmitCompilerGeneratedFiles`, so generated telemetry can be inspected in `obj/*/generated/` to sanity-check generator output. @@ -177,7 +181,7 @@ Treat work as incomplete until every gate relevant to it passes. Before a releas - `just verify-pack-rules` after any change to `PackValidation` in `.config/purview-build.json` (it is also the last step of `just pipeline-pack-validate`, so packing locally proves the rules without a second command). - `just build-s && just test-s` — the samples, which reference the generator as an analyzer project. -- `just lint` — CSharpier reports no change (`just lint-fix` to apply it). +- `just lint-check` — CSharpier reports no change (`just lint-fix` to apply it). Compiler-host assemblies (`Purview.SourceGeneratorFramework*`, `Microsoft.CodeAnalysis*`, and this repository's `Purview.Telemetry.SourceGenerator*` generator and refactorings) must never reach an @@ -187,7 +191,7 @@ any CI job that builds the samples or publishes an application must run it on wh tests keep the leak scan honest against crafted leaks, keep the `packed-consumers` job, the Justfile recipes and every `ForbiddenContent` rule wired up, and require the consumers in `scripts/packed-consumers/` to reference the generator as a package, never as a project. The full -table of gates is in [`docs/wiki/Release-Flow.md`](docs/wiki/Release-Flow.md#release-gates). +table of gates is in [`docs/wiki/contributing/Release-Flow.md`](docs/wiki/contributing/Release-Flow.md#release-gates). ### Test project details @@ -198,7 +202,7 @@ table of gates is in [`docs/wiki/Release-Flow.md`](docs/wiki/Release-Flow.md#rel - Gate new emitted APIs on the compilation (`Compilation.GetTypeByMetadataName`, surfaced through `TelemetryCapabilities`), not on the target framework: the System.Diagnostics.DiagnosticSource package brings .NET 11 types to `net462`/`netstandard2.0`. The supported matrix and the list of .NET 11 features that are not generated - yet live in [`docs/wiki/Platform-Support.md`](docs/wiki/Platform-Support.md); update it with any such change. + yet live in [`docs/wiki/reference/Platform-Support.md`](docs/wiki/reference/Platform-Support.md); update it with any such change. - Tests use `Purview.SourceGeneratorFramework.Testing.TUnit` (TUnit + the framework's `CodeQuery` syntax-lookup API and assertion extensions). There is no Verify/snapshot library; refactoring tests use the framework's `CodeRefactoringTestBase` snapshot approach. @@ -208,7 +212,7 @@ table of gates is in [`docs/wiki/Release-Flow.md`](docs/wiki/Release-Flow.md#rel - **Conventional commits**: enforced by commitlint (`.config/lefthook.yml`). Types: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`. -- **Formatting**: C# is formatted with csharpier; check with `just lint` and fix with `just lint-fix`. CSharpier +- **Formatting**: C# is formatted with csharpier; check with `just lint-check` and fix with `just lint-fix`. CSharpier 1.4.0 parses C# 15, so the two files that declare a `closed` hierarchy are formatted like any other and the `.csharpierignore` those entries lived in is gone. Do not reintroduce an ignore file to work around the parser; a file it cannot read is a version bump, not an exclusion. @@ -243,7 +247,7 @@ table of gates is in [`docs/wiki/Release-Flow.md`](docs/wiki/Release-Flow.md#rel C# 15 updated memory-safety rules (`MemorySafetyRules=2`), with `AllowUnsafeBlocks` left off. Do not add `Features` or `MemorySafetyRules` to a project file; set `PurviewRuntimeAsync=false` / `MemorySafetyRules=0` to opt one project out. `.NET 10` and `netstandard2.0` legs are deliberately untouched. - `docs/wiki/Platform-Support.md` records every .NET 11 feature that was evaluated and why it is or is not used. + `docs/wiki/reference/Platform-Support.md` records every .NET 11 feature that was evaluated and why it is or is not used. - **Dependency versions are lower bounds.** `Directory.Packages.props` keeps one property per package family — `DotnetRuntimeVersion` for the runtime-tracked `10.0.` line, `MSExtensionVersion` for dotnet/extensions' `10..0` — and pins the lowest supported framework line (`net10.0`), never the @@ -294,7 +298,7 @@ skill cannot drift into `skills/`. | Developer skill | Answers | | --- | --- | -| `.agents/skills/purview-telemetry-benchmarks/` | running the BenchmarkDotNet suite for .NET 10 / .NET 11 / .NET Framework 4.8, reading a report, refreshing `docs/wiki/Performance.md` and `README.md`, writing the analysis, adding a family | +| `.agents/skills/purview-telemetry-benchmarks/` | running the BenchmarkDotNet suite for .NET 10 / .NET 11 / .NET Framework 4.8, reading a report, refreshing `docs/wiki/reference/Performance.md` and `README.md`, writing the analysis, adding a family | Skills are installed into the universal `.agents/skills/` location **only**. Never install copies into `.claude/` or any other per-agent folder. Pass `-a universal`, which writes `.agents/skills/` @@ -391,7 +395,7 @@ prose instead, and keep deep detail in the skill's own `references/`. > `README.md`. Never copy stale results or estimate values — every performance table must come from a > current run. -The full workflow — running the suite, reading a report, refreshing `docs/wiki/Performance.md` and +The full workflow — running the suite, reading a report, refreshing `docs/wiki/reference/Performance.md` and `README.md`, writing the analysis and adding a family — is the developer skill `.agents/skills/purview-telemetry-benchmarks/`. Load it before touching a performance number; the points below are the constraints it depends on. @@ -420,5 +424,6 @@ See [`docs/release-process.md`](docs/release-process.md) for the full flow. In s 2. Merging to `main` triggers `release.yml`, which runs the reusable `purview-dev/build/.github/workflows/purview-release.yml` pipeline (build, test, pack, publish, GitHub release). -3. The version is read from `package.json`. Bump it and run `just update-version` to sync docs/samples, - then use `just pipeline-local-release` to validate locally or `just pipeline-release` to publish. \ No newline at end of file +3. The version is read from `package.json`. Bump it with `just bump major|minor|patch`, which runs + `update-version` for you to sync docs/samples, then use `just pipeline-local-release` to validate + locally or `just pipeline-release` to publish. \ No newline at end of file diff --git a/Justfile b/Justfile index 7154c42f..59d78169 100644 --- a/Justfile +++ b/Justfile @@ -1,161 +1,82 @@ +# Recipes for the Purview Telemetry Source Generator. Run `just` for the list. +# +# The shared recipes - restore, build, test, clean, pack, lint, scrub, the pipelines, the skill +# targets and the version - come from .build/just/, copied verbatim from the purview-buildsdk-justfile +# skill. Change them there, not here; this file holds what belongs to this repository. + set quiet +# Let a definition here override a module's default variable or no-op hook. +set allow-duplicate-variables +set allow-duplicate-recipes + +import ".build/just/index.just" + +# ----------------------------------------------------------------------------- +# This repository +# ----------------------------------------------------------------------------- + root_folder := "./src/" solution_file := root_folder + "Telemetry.SourceGenerator.slnx" -test_solution := solution_file build_configuration := "Debug" - -pipeline_tool := "dotnet purview-build" +artifacts_folder := "./artifacts/" sample_solution_file := "./samples/SampleApp/SampleApp.slnx" multiproject_sample_solution_file := "./samples/SampleApp.MultiProject/SampleApp.MultiProject.slnx" multiproject_sample_project := "./samples/SampleApp.MultiProject/SampleApp.MultiProject.ConsoleApp" -artifact_folder := "./artifacts/" benchmark_solution := "./benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj" # Displays the list of available commands - [private] default: just --list -# Restore the repository's .NET local tools (.config/dotnet-tools.json) -[private] -ensure-pipeline-tool: - dotnet tool restore - -# Run the PR pipeline (restore, build, lint, tests) -[group('Pipeline')] -pipeline-pr *args: - just 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 - 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 - echo "Running release pipeline..." - {{ pipeline_tool }} --Release:Mode=NuGet {{ args }} - -# Run the release pipeline (restore, build, lint, tests, pack, local nuget publish) -# Note: `just` runs recipes through the shell, which strips backslashes from unquoted arguments. -# Use the LOCAL_NUGET_FEED_PATH environment variable or forward slashes, e.g. -# just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ -[group('Pipeline')] -pipeline-local-release *args: - just ensure-pipeline-tool - echo "Running local release pipeline..." - {{ pipeline_tool }} --Release:Mode=LocalNuGet {{ args }} - -# Pack the generator to an isolated feed and cache, then build, publish and run throwaway consumers that reference -# the package only: a Native AOT console, a Blazor WebAssembly app (trimmed, plus RunAOTCompilation when wasm-tools is -# installed) and a Blazor Server host, each leak-scanned for compiler-host assemblies. -# -# The tests and samples reference the generator project; this is the gate that proves what ships. The CI -# `packed-consumers` job runs it for linux-x64. Purview.SourceGeneratorFramework and Purview.BuildSdk are restored -# from nuget.org, plus TELEMETRY_PRERELEASE_FEED or LOCAL_NUGET_FEED_PATH when set (or pass -PrereleaseFeed). -[group('Pipeline')] -smoke-packed-consumers *args: - echo "Running the packed consumer gate..." - pwsh -NoProfile -File scripts/test-packed-consumers.ps1 {{ args }} - -# Prove every PackValidation ForbiddenContent rule in .config/purview-build.json rejects a package: injects a matching -# file into scratch copies of the packed package and runs the real purview-build validation on them. Reads artifacts/, -# so pack first (`just pipeline-pack-validate`, which runs this recipe itself). `just pipeline-local-release` moves its -# packages to the local feed, so pass -PackagesFolder p:/_sync-projects/.local-nuget after it. -[group('Pipeline')] -verify-pack-rules *args: - echo "Proving the pack-validation forbidden-content rules..." - pwsh -NoProfile -File scripts/test-pack-validation-rules.ps1 {{ args }} - -# Run the pipeline with tests enabled -[group('Pipeline')] -pipeline-tests *args: - just 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. Finishes by proving every ForbiddenContent rule rejects a package (`verify-pack-rules`, which -# reads artifacts/ - pass -Build:ArtifactsFolder to that recipe if args moved this run's packages somewhere else). -[group('Pipeline')] -pipeline-pack-validate *args: - just ensure-pipeline-tool - echo "Running pack + validate pipeline..." - {{ pipeline_tool }} --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} - just verify-pack-rules - # ----------------------------------------------------------------------------- # Build and Test # ----------------------------------------------------------------------------- -# Builds the solution with the specified configuration (default: Release) - -[group('Build and Test')] -build *args: - echo "Building {{ BLUE }}{{ solution_file }}{{ NORMAL }} with {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}..." - dotnet build "{{ solution_file }}" --configuration "{{ build_configuration }}" {{ args }} - -# Restore NuGet packages for the solution -[group('Build and Test')] -restore *args: - dotnet restore {{ solution_file }} {{ args }} - -# Runs tests for the solution with the specified configuration (default: Release) +# Run the tests matching a tree-node filter (default: every test), across all four legs # -# Uses the .NET 11 Microsoft.Testing.Platform run-level switches. They apply to the whole run across every -# test application (net48, net10.0, net11.0 and Repository.UnitTests): +# Overrides the module's recipe, which targets a single test project: this solution holds four test +# projects (net48, net10.0, net11.0 and Repository.UnitTests), so it is run as a solution. The +# Microsoft.Testing.Platform run-level switches then apply to the whole run: # --timeout caps total execution time, so a hung test application cannot stall CI # --maximum-failed-tests stops at the first N failures instead of burning the whole run # --results-directory-layout per-module gives every project/TFM its own /_ folder, so the # four legs can no longer overwrite one another's report files # These are not VSTest switches: VSTest mode rejects them. +[doc('Run the tests matching a tree-node filter (default: every test), across all four legs')] [group('Build and Test')] -test filter="/*/*/*/*/" *args: - echo "Running tests for {{ BLUE }}{{ test_solution }}{{ NORMAL }} with {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}..." - dotnet test --solution "{{ test_solution }}" --configuration "{{ build_configuration }}" --treenode-filter={{ filter }} --timeout 30m --maximum-failed-tests 25 --results-directory-layout per-module {{ args }} +test filter=test_filter *args: + echo "==> Testing {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ YELLOW }}{{ build_configuration }}{{ NORMAL }}) with filter {{ GREEN }}{{ filter }}{{ NORMAL }}" + dotnet test --solution "{{ solution_file }}" --configuration "{{ build_configuration }}" --treenode-filter "{{ filter }}" --timeout 30m --maximum-failed-tests 25 --results-directory-layout per-module {{ args }} -# Runs the test solution against every declared dependency floor (`DependencyFloor=true`), proving the +# Run the test solution against every declared dependency floor (`DependencyFloor=true`), proving the # lowest common .NET 10 versions this package declares still compile and pass. The ordinary `test` # recipe resolves the latest servicing patch of the same line, which is what a consumer actually gets. # # `Directory.Packages.props` holds one property per package family, so the floor is a switch rather than # a second file, and this is the leg that proves it. `ReleaseGateTests` keeps the recipe and the PR job # wired up, so a declared floor cannot drift away from the version that was tested. +[doc('Run the tests against every declared dependency floor (`DependencyFloor=true`)')] [group('Build and Test')] -test-floor filter="/*/*/*/*/" *args: - echo "Running tests against the dependency floor for {{ test_solution }}..." - dotnet test --solution "{{ test_solution }}" --configuration "{{ build_configuration }}" --treenode-filter={{ filter }} --timeout 30m --maximum-failed-tests 25 --results-directory-layout per-module -p:DependencyFloor=true {{ args }} - -# Scan build or publish output for compiler-host assemblies (the generator, the refactorings, -# Purview.SourceGeneratorFramework, Roslyn) that must never reach an application. -[group('Build and Test')] -scan-compiler-host-leaks +paths: - pwsh -NoProfile -File scripts/assert-no-compiler-host-assemblies.ps1 -Path {{ paths }} - -# Cleans the solution with the specified configuration (default: Release) -[group('Build and Test')] -clean *args: - echo "Cleaning {{ BLUE }}{{ solution_file }}{{ NORMAL }} with {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}..." - dotnet clean "{{ solution_file }}" --configuration "{{ build_configuration }}" {{ args }} - -# Packs the source generator into a NuGet package +test-floor filter=test_filter *args: + echo "==> Testing {{ BLUE }}{{ solution_file }}{{ NORMAL }} against the dependency floor with filter {{ GREEN }}{{ filter }}{{ NORMAL }}" + dotnet test --solution "{{ solution_file }}" --configuration "{{ build_configuration }}" --treenode-filter "{{ filter }}" --timeout 30m --maximum-failed-tests 25 --results-directory-layout per-module -p:DependencyFloor=true {{ args }} +# Pack the source generator, stamping the version, branch, commit and copyright year into the package +# +# Overrides the module's `dotnet pack` recipe: `.build/build-pack.ts` packs the one packable project +# with that metadata, and the version it packs is the one `update-version` just synced into the +# docs and samples. `just pipeline-pack-validate` is the gate that proves what it wrote. +[doc('Pack the source generator, stamping the version, branch, commit and copyright year into the package')] [group('Build and Test')] pack: update-version build-pack # Packs the source generator into a NuGet package and outputs it to the # specified folder, including version, branch, commit, and copyright year # information in the package metadata. - +[doc('Pack the source generator into a NuGet package with version, branch, commit and copyright metadata')] [group('Build and Test')] build-pack: bun -e "const version = require('./package.json').version; console.log('Packing {{ BLUE }}Source Generator{{ NORMAL }} with {{ YELLOW }}' + version + '{{ NORMAL }}...');" @@ -163,88 +84,116 @@ build-pack: bun -e "const { execFileSync } = require('child_process'); console.log(' Branch: {{ GREEN }}' + execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8' }).trim() + '{{ NORMAL }}');" bun -e "const { execFileSync } = require('child_process'); console.log(' Commit: {{ GREEN }}' + execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() + '{{ NORMAL }}');" bun -e "console.log(' Copyright Year: {{ GREEN }}' + new Date().getFullYear() + '{{ NORMAL }}');" - echo " Output Folder: {{ GREEN }}{{ artifact_folder }}{{ NORMAL }}" - bun .build/build-pack.ts "{{ root_folder }}" "{{ build_configuration }}" "{{ artifact_folder }}" + echo " Output Folder: {{ GREEN }}{{ artifacts_folder }}{{ NORMAL }}" + bun .build/build-pack.ts "{{ root_folder }}" "{{ build_configuration }}" "{{ artifacts_folder }}" # ----------------------------------------------------------------------------- # Samples # ----------------------------------------------------------------------------- # Builds the sample solution with the specified configuration (default: Release) - [group('Samples - Build and Test')] build-s: echo "Building {{ BLUE }}{{ sample_solution_file }}{{ NORMAL }} with {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}..." dotnet build "{{ sample_solution_file }}" --configuration "{{ build_configuration }}" # Runs tests for the sample solution with the specified configuration - [group('Samples - Build and Test')] test-s: echo "Running tests for {{ BLUE }}{{ sample_solution_file }}{{ NORMAL }} with {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}..." dotnet test --solution "{{ sample_solution_file }}" --configuration "{{ build_configuration }}" # Builds the multi-project sample solution with the specified configuration - [group('Samples - Build and Test')] build-mp: echo "Building {{ BLUE }}{{ multiproject_sample_solution_file }}{{ NORMAL }} with {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}..." dotnet build "{{ multiproject_sample_solution_file }}" --configuration "{{ build_configuration }}" # Runs the multi-project sample, printing the aggregated telemetry names - [group('Samples - Build and Test')] run-mp: echo "Running {{ BLUE }}{{ multiproject_sample_project }}{{ NORMAL }} with {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}..." dotnet run --project "{{ multiproject_sample_project }}" --configuration "{{ build_configuration }}" # ----------------------------------------------------------------------------- -# Formatting +# Pipeline gates # ----------------------------------------------------------------------------- -# Formats the code in the root folder -format: - echo "Formatting {{ BLUE }}{{ root_folder }}{{ NORMAL }}..." - dotnet format "{{ root_folder }}" +# Pack the generator to an isolated feed and cache, then build, publish and run throwaway consumers that reference +# the package only: a Native AOT console, a Blazor WebAssembly app (trimmed, plus RunAOTCompilation when wasm-tools is +# installed) and a Blazor Server host, each leak-scanned for compiler-host assemblies. +# +# The tests and samples reference the generator project; this is the gate that proves what ships. The CI +# `packed-consumers` job runs it for linux-x64. Purview.SourceGeneratorFramework and Purview.BuildSdk are restored +# from nuget.org, plus TELEMETRY_PRERELEASE_FEED or LOCAL_NUGET_FEED_PATH when set (or pass -PrereleaseFeed). +[doc('Pack the generator and prove the package works for Native AOT, Blazor WebAssembly and Blazor Server consumers')] +[group('Pipeline')] +smoke-packed-consumers *args: + echo "==> Running the packed consumer gate" + pwsh -NoProfile -File scripts/test-packed-consumers.ps1 {{ args }} -# Checks for linting issues in the root folder -lint: - echo "Linting checking {{ BLUE }}{{ root_folder }}{{ NORMAL }}..." - dotnet csharpier check . +# Prove every PackValidation ForbiddenContent rule in .config/purview-build.json rejects a package: injects a matching +# file into scratch copies of the packed package and runs the real purview-build validation on them. Reads artifacts/, +# so pack first (`just pipeline-pack-validate`, which runs this recipe itself). `just pipeline-local-release` moves its +# packages to the local feed, so pass -PackagesFolder p:/_sync-projects/.local-nuget after it. +[doc('Prove every PackValidation ForbiddenContent rule rejects a package')] +[group('Pipeline')] +verify-pack-rules *args: + echo "==> Proving the pack-validation forbidden-content rules" + pwsh -NoProfile -File scripts/test-pack-validation-rules.ps1 {{ args }} -# Fixes linting issues in the root folder -lint-fix: - echo "Linting fixing {{ BLUE }}{{ root_folder }}{{ NORMAL }}..." - dotnet csharpier format . +# Run the pipeline through pack and pack validation, then prove every ForbiddenContent rule rejects a package +# +# Overrides the module's recipe so that packing locally proves the rules in .config/purview-build.json +# without a second command. It reads artifacts/ - pass -Build:ArtifactsFolder if args moved this run's +# packages somewhere else. +[doc('Run the pipeline through pack and pack validation, then prove every ForbiddenContent rule rejects a package')] +[group('Pipeline')] +pipeline-pack-validate *args: ensure-tools + echo "==> Running the pack and validate pipeline" + {{ pipeline_tool }} --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} + just verify-pack-rules -# Displays the current version of the project. -# Requires Bun. -[group('Versioning and Release')] -version: - bun -e "console.log('Current Version: {{ GREEN }}' + require('./package.json').version + '{{ NORMAL }}')" +# Scan build or publish output for compiler-host assemblies (the generator, the refactorings, +# Purview.SourceGeneratorFramework, Roslyn) that must never reach an application. +[doc('Scan build or publish output for compiler-host assemblies')] +[group('Pipeline')] +scan-compiler-host-leaks +paths: + pwsh -NoProfile -File scripts/assert-no-compiler-host-assemblies.ps1 -Path {{ paths }} -# Updates related samples and documentation to the new version. -# Requires Bun. +# ----------------------------------------------------------------------------- +# Versioning +# ----------------------------------------------------------------------------- -[group('Versioning and Release')] +# Sync the version in package.json into the docs and samples +# +# Runs after every `just bump`, through `_after-bump` below, so a release needs only the bump. A +# hand-edited package.json still needs it run by hand, before `just pack`. +[doc('Sync the version in package.json into the docs and samples')] +[group('Version')] update-version: - bun -e "console.log('Update related samples and docs to new version: {{ GREEN }}' + require('./package.json').version + '{{ NORMAL }}')" + echo "==> Updating related samples and docs to version {{ GREEN }}{{ current_version }}{{ NORMAL }}" git submodule update --init --recursive bun .build/update-version.ts +# Runs after a successful `just bump`: the version is pinned in the docs and samples as well. +_after-bump: update-version + # ----------------------------------------------------------------------------- -# System / Shell +# Utilities # ----------------------------------------------------------------------------- -# Opens the solution in the default associated application - +# Format the C# with `dotnet format` +# +# CSharpier is the check that gates a commit (`just lint-check`); this is the IDE-style pass over the +# whole of src/, kept because editors here still defer to it. +[doc('Format the C# with `dotnet format`')] [group('Utilities')] -vs: - echo "Opening {{ BLUE }}{{ solution_file }}{{ NORMAL }}..." - open "{{ solution_file }}" +format: + echo "Formatting {{ BLUE }}{{ root_folder }}{{ NORMAL }}..." + dotnet format "{{ root_folder }}" # Opens the root folder in Visual Studio Code - [group('Utilities')] code: echo "Opening {{ BLUE }}Visual Studio Code{{ NORMAL }}..." @@ -262,14 +211,6 @@ vs-mp: echo "Opening {{ BLUE }}{{ multiproject_sample_solution_file }}{{ NORMAL }}..." open "{{ multiproject_sample_solution_file }}" -# Clean up the repository by removing build artifacts, bin/obj folders etc, and shutting down the build server -[group('Utilities')] -scrub: - find . -type d \( -name bin -o -name obj \) -exec rm -rf {} + - just clean - just restore --force-evaluate - dotnet build-server shutdown - # ----------------------------------------------------------------------------- # Benchmarking # ----------------------------------------------------------------------------- @@ -280,6 +221,7 @@ scrub: # are not meaningful. Every job names its runtime explicitly (`RuntimeMoniker.Net10_0`, `Net11_0`, `Net48`), # so one report carries all three runtimes and the launch framework only selects the BenchmarkDotNet host. # `Net11_0` needs BenchmarkDotNet 0.16.0-preview.2 or later; there is no stable release with it yet. +[doc('Run the full benchmark suite for .NET 10, .NET 11 and .NET Framework 4.8 in one invocation')] [group('Benchmarking')] benchmark: echo "Running benchmarks for {{ BLUE }}{{ benchmark_solution }}{{ NORMAL }} with {{ YELLOW }}Release{{ NORMAL }}..." @@ -295,13 +237,13 @@ benchmark: # # Its own artifacts folder keeps it from overwriting the full suite's report. # Use `just benchmark` for the full three-runtime suite. +[doc('Run a quick single-runtime (.NET 11) benchmark with reduced iterations')] [group('Benchmarking')] benchmark-quick: echo "Running quick benchmarks (net11.0, short job) for {{ BLUE }}{{ benchmark_solution }}{{ NORMAL }} with {{ YELLOW }}Release{{ NORMAL }}..." dotnet run --project "{{ benchmark_solution }}" --configuration Release --framework net11.0 -- --job short --runtimes net11.0 --artifacts BenchmarkDotNet.Artifacts/quick # Runs benchmarks and reminds you to update performance documentation - [group('Benchmarking')] benchmark-docs: benchmark echo "" @@ -313,7 +255,7 @@ benchmark-docs: benchmark echo " - Logging: use {{ BLUE }}*LoggerBenchmarks*-report-github.md{{ NORMAL }}" echo " - Multi-target: use {{ BLUE }}*MultiTarget*-report-github.md{{ NORMAL }}" echo " - Metrics: use {{ BLUE }}*MetricsBenchmarks*-report-github.md{{ NORMAL }}" - echo " 2. Regenerate {{ BLUE }}docs/wiki/Performance.md{{ NORMAL }} from all six *-report-github.md files, one section per family with a row per runtime." + echo " 2. Regenerate {{ BLUE }}docs/wiki/reference/Performance.md{{ NORMAL }} from all six *-report-github.md files, one section per family with a row per runtime." echo " 3. Update the environment header (machine / SDK / runtime versions) in both files." echo "" echo " See {{ BLUE }}AGENTS.md{{ NORMAL }} - Benchmarking and performance documentation for the full rules." diff --git a/docs/wiki/Contributing.md b/docs/wiki/contributing/Contributing.md similarity index 100% rename from docs/wiki/Contributing.md rename to docs/wiki/contributing/Contributing.md diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/contributing/Release-Flow.md similarity index 100% rename from docs/wiki/Release-Flow.md rename to docs/wiki/contributing/Release-Flow.md diff --git a/docs/wiki/Installation.md b/docs/wiki/guide/Installation.md similarity index 100% rename from docs/wiki/Installation.md rename to docs/wiki/guide/Installation.md diff --git a/docs/wiki/Sample-Application.md b/docs/wiki/guide/Sample-Application.md similarity index 100% rename from docs/wiki/Sample-Application.md rename to docs/wiki/guide/Sample-Application.md diff --git a/docs/wiki/Testing.md b/docs/wiki/guide/Testing.md similarity index 100% rename from docs/wiki/Testing.md rename to docs/wiki/guide/Testing.md diff --git a/docs/wiki/Migration-From-Activities.md b/docs/wiki/migration/Migration-From-Activities.md similarity index 100% rename from docs/wiki/Migration-From-Activities.md rename to docs/wiki/migration/Migration-From-Activities.md diff --git a/docs/wiki/Migration-From-ILogger.md b/docs/wiki/migration/Migration-From-ILogger.md similarity index 100% rename from docs/wiki/Migration-From-ILogger.md rename to docs/wiki/migration/Migration-From-ILogger.md diff --git a/docs/wiki/Breaking-Changes.md b/docs/wiki/reference/Breaking-Changes.md similarity index 100% rename from docs/wiki/Breaking-Changes.md rename to docs/wiki/reference/Breaking-Changes.md diff --git a/docs/wiki/Diagnostics.md b/docs/wiki/reference/Diagnostics.md similarity index 100% rename from docs/wiki/Diagnostics.md rename to docs/wiki/reference/Diagnostics.md diff --git a/docs/wiki/FAQ.md b/docs/wiki/reference/FAQ.md similarity index 100% rename from docs/wiki/FAQ.md rename to docs/wiki/reference/FAQ.md diff --git a/docs/wiki/Generated-Output.md b/docs/wiki/reference/Generated-Output.md similarity index 100% rename from docs/wiki/Generated-Output.md rename to docs/wiki/reference/Generated-Output.md diff --git a/docs/wiki/Performance.md b/docs/wiki/reference/Performance.md similarity index 100% rename from docs/wiki/Performance.md rename to docs/wiki/reference/Performance.md diff --git a/docs/wiki/Platform-Support.md b/docs/wiki/reference/Platform-Support.md similarity index 100% rename from docs/wiki/Platform-Support.md rename to docs/wiki/reference/Platform-Support.md diff --git a/docs/wiki/Refactorings.md b/docs/wiki/reference/Refactorings.md similarity index 100% rename from docs/wiki/Refactorings.md rename to docs/wiki/reference/Refactorings.md diff --git a/docs/wiki/Activities.md b/docs/wiki/telemetry/Activities.md similarity index 100% rename from docs/wiki/Activities.md rename to docs/wiki/telemetry/Activities.md diff --git a/docs/wiki/Logging-Generation-v1.md b/docs/wiki/telemetry/Logging-Generation-v1.md similarity index 100% rename from docs/wiki/Logging-Generation-v1.md rename to docs/wiki/telemetry/Logging-Generation-v1.md diff --git a/docs/wiki/Logging-Generation-v2.md b/docs/wiki/telemetry/Logging-Generation-v2.md similarity index 100% rename from docs/wiki/Logging-Generation-v2.md rename to docs/wiki/telemetry/Logging-Generation-v2.md diff --git a/docs/wiki/Logging.md b/docs/wiki/telemetry/Logging.md similarity index 100% rename from docs/wiki/Logging.md rename to docs/wiki/telemetry/Logging.md diff --git a/docs/wiki/Metrics.md b/docs/wiki/telemetry/Metrics.md similarity index 100% rename from docs/wiki/Metrics.md rename to docs/wiki/telemetry/Metrics.md diff --git a/docs/wiki/Multi-Targeting.md b/docs/wiki/telemetry/Multi-Targeting.md similarity index 100% rename from docs/wiki/Multi-Targeting.md rename to docs/wiki/telemetry/Multi-Targeting.md diff --git a/docs/wiki/Tags-and-Baggage.md b/docs/wiki/telemetry/Tags-and-Baggage.md similarity index 100% rename from docs/wiki/Tags-and-Baggage.md rename to docs/wiki/telemetry/Tags-and-Baggage.md From bb55a9a6c6add79d9d9e34ddd88a7593efa47c57 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sun, 15 Nov 2026 00:34:12 +0000 Subject: [PATCH 05/12] docs(wiki): split the documentation into topic folders 24 pages sat in one flat folder, so the sidebar was a list of 24 links and the URL of every page was one segment. They now live in guide/, telemetry/, reference/, migration/ and contributing/, with Home, Getting-Started and Generation left at the root: the catalogue names Getting-Started as its rootPage and deep-links generation from a use case. _redirects.yml records all 21 moves, so a bookmark or a link from another repository still resolves; the sidebar groups the pages in reading order, and 148 relative links were rewritten to resolve from their page's new folder. Every inbound link follows: README.md and the samples' README link the canonical purview.dev URLs rather than relying on the redirect, and the diagnostic links in AnalyzerReleases.Unshipped.md, the source comments and the benchmark skill name the new paths. The ASCII release-flow diagram (it appeared twice) became a LikeC4 model paired with a Mermaid fence, the two unlabelled fences are labelled, and the four pages that opened straight into a callout now open with the paragraph the site uses as its meta description. --- .../purview-telemetry-benchmarks/SKILL.md | 14 ++--- .../references/performance-doc-shapes.md | 2 +- CHANGELOG.md | 12 ++-- README.md | 20 +++---- benchmarks/README.md | 2 +- docs/release-process.md | 26 +++++---- docs/wiki/Generation.md | 12 ++-- docs/wiki/Getting-Started.md | 16 ++--- docs/wiki/Home.md | 58 +++++++++---------- docs/wiki/_Sidebar.md | 47 ++++++++------- docs/wiki/_redirects.yml | 33 +++++++++++ docs/wiki/contributing/Contributing.md | 12 ++-- docs/wiki/contributing/Release-Flow.md | 26 +++++---- docs/wiki/diagrams/release-flow.c4 | 40 +++++++++++++ docs/wiki/guide/Installation.md | 12 ++-- docs/wiki/guide/Sample-Application.md | 10 ++-- docs/wiki/guide/Testing.md | 2 +- .../migration/Migration-From-Activities.md | 14 ++--- docs/wiki/migration/Migration-From-ILogger.md | 16 ++--- docs/wiki/reference/Breaking-Changes.md | 4 +- docs/wiki/reference/Diagnostics.md | 12 ++-- docs/wiki/reference/FAQ.md | 16 ++--- docs/wiki/reference/Generated-Output.md | 12 ++-- docs/wiki/reference/Performance.md | 2 +- docs/wiki/reference/Platform-Support.md | 12 ++-- docs/wiki/reference/Refactorings.md | 4 +- docs/wiki/telemetry/Activities.md | 8 +-- docs/wiki/telemetry/Logging-Generation-v1.md | 6 +- docs/wiki/telemetry/Logging-Generation-v2.md | 6 +- docs/wiki/telemetry/Logging.md | 6 +- docs/wiki/telemetry/Metrics.md | 6 +- docs/wiki/telemetry/Multi-Targeting.md | 2 +- docs/wiki/telemetry/Tags-and-Baggage.md | 8 ++- samples/SampleApp/README.md | 4 +- .../AnalyzerReleases.Unshipped.md | 22 +++---- .../Helpers/LogPropertiesFlattener.cs | 4 +- .../Helpers/TagProviderResolver.cs | 2 +- ...SourceGeneratorMetricsTests.MeterNaming.cs | 2 +- 38 files changed, 302 insertions(+), 210 deletions(-) create mode 100644 docs/wiki/_redirects.yml create mode 100644 docs/wiki/diagrams/release-flow.c4 diff --git a/.agents/skills/purview-telemetry-benchmarks/SKILL.md b/.agents/skills/purview-telemetry-benchmarks/SKILL.md index 8614abd4..bdcd910b 100644 --- a/.agents/skills/purview-telemetry-benchmarks/SKILL.md +++ b/.agents/skills/purview-telemetry-benchmarks/SKILL.md @@ -2,7 +2,7 @@ name: purview-telemetry-benchmarks description: >- Maintainer workflow for this repository's BenchmarkDotNet suite - running the .NET 10, .NET 11 and - .NET Framework 4.8 jobs, reading a -report-github.md, refreshing docs/wiki/Performance.md and + .NET Framework 4.8 jobs, reading a -report-github.md, refreshing docs/wiki/reference/Performance.md and README.md from a fresh run, writing the analysis consumers read, and adding a benchmark family. Use when asked to run, update, interpret, or extend the benchmark results. This is a developer skill for this repository; it is never published to consumers. @@ -52,7 +52,7 @@ Load this skill before you quote, edit, or add a performance number. The first six measure code a consumer runs. `GeneratorModelBenchmarks` measures the generator's own data model and is contributor-facing: it backs the recorded decision in -`docs/wiki/Platform-Support.md` to keep the `closed record` hierarchies. Do not promote it into the +`docs/wiki/reference/Platform-Support.md` to keep the `closed record` hierarchies. Do not promote it into the README's consumer performance section. Exact table shapes, section headings and column headers for each family live in @@ -144,7 +144,7 @@ Column semantics: Do these in order, from the same run. Nothing below is optional because "it looked the same". -1. **`docs/wiki/Performance.md`** - the full cross-runtime page. +1. **`docs/wiki/reference/Performance.md`** - the full cross-runtime page. - `## Environment`: replace the fenced block with the fresh report header (BenchmarkDotNet, OS, CPU, SDK, and one line per measured runtime). - One section per family, in the existing order, each with a row per runtime and per parameter @@ -167,7 +167,7 @@ Do these in order, from the same run. Nothing below is optional because "it look 3. **`benchmarks/README.md`** - only when the suite itself changed: the family table, the "runtimes and jobs" table, the per-family detail sections and the project-structure tree all list the classes and the monikers. -4. **`docs/wiki/Platform-Support.md`** - when `GeneratorModelBenchmarks` numbers back (or contradict) +4. **`docs/wiki/reference/Platform-Support.md`** - when `GeneratorModelBenchmarks` numbers back (or contradict) the recorded decision to keep the `closed record` models, update that row's reasoning. It currently says the tables predate the benchmark; once they do not, say so. 5. **`AGENTS.md`** - the "Benchmarking and performance documentation" section carries the commands, the @@ -238,7 +238,7 @@ the `HasListener = false` row as the production cost, and describing a family th inlined into the benchmark loop and an interface call is not, so a generated-through-interface vs hand-written-through-concrete pair measures dispatch and reports it as generator overhead — a flat per-call delta that ignores the work the method does. -5. Register it: `benchmarks/README.md` (family table + project tree), `docs/wiki/Performance.md` (new +5. Register it: `benchmarks/README.md` (family table + project tree), `docs/wiki/reference/Performance.md` (new section with one row per runtime), and `README.md` only if it is a consumer hot path. 6. Validate as described below, then run the full suite - a new family cannot be documented from a filtered run that never executed it on all three legs. @@ -254,13 +254,13 @@ characteristic and fails BenchmarkDotNet's `GetSupportedBenchmarks` validation i - It exists to answer one question: is the per-method allocation and virtual dispatch of the record hierarchy measurable against a pass that also parses symbols, builds templates and writes source? A delta of tens of nanoseconds per method against a generation pass is not a throughput story. -- Report it in `docs/wiki/Performance.md` only, with a row per runtime and per `MethodCount`. A +- Report it in `docs/wiki/reference/Performance.md` only, with a row per runtime and per `MethodCount`. A `.NET Framework` result that is *slower* for the struct union is a real outcome, not a mistake - it is part of why the records stayed. ## Validate before committing -- `just lint` - CSharpier, no writes. `just lint-fix` to apply. Any C# you touched in `benchmarks/` is +- `just lint-check` - CSharpier, no writes. `just lint-fix` to apply. Any C# you touched in `benchmarks/` is formatted by the same check. - `dotnet build ./benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj --configuration Release` - a compile check of a new or edited family that costs seconds instead of a benchmark run. Note that diff --git a/.agents/skills/purview-telemetry-benchmarks/references/performance-doc-shapes.md b/.agents/skills/purview-telemetry-benchmarks/references/performance-doc-shapes.md index 8efae4d7..b47d6d33 100644 --- a/.agents/skills/purview-telemetry-benchmarks/references/performance-doc-shapes.md +++ b/.agents/skills/purview-telemetry-benchmarks/references/performance-doc-shapes.md @@ -3,7 +3,7 @@ The exact shapes the two performance documents use today. Preserve them: a consumer reads the same table layout release after release, and a diff should show changed numbers, not a reshaped page. -## `docs/wiki/Performance.md` +## `docs/wiki/reference/Performance.md` Order of sections: diff --git a/CHANGELOG.md b/CHANGELOG.md index ebf9f0c3..1dd2f784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to `Purview.Telemetry.SourceGenerator`. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions come from the `version` field in `package.json`, which is the single source of truth. This file starts at 5.0.7; earlier releases are described by their [GitHub releases](https://github.com/purview-dev/telemetry-sourcegenerator/releases) -and, for breaking changes, by [Breaking Changes](docs/wiki/Breaking-Changes.md). +and, for breaking changes, by [Breaking Changes](docs/wiki/reference/Breaking-Changes.md). ## [Unreleased] @@ -44,7 +44,7 @@ and, for breaking changes, by [Breaking Changes](docs/wiki/Breaking-Changes.md). - **`GeneratorModelBenchmarks`.** Compares the generator's `closed record` tagged models against a value-based `[Union]` struct on every benchmark runtime leg. It backs the recorded decision to keep the `closed` records: the measurable win is a fraction of a generation pass, and on .NET Framework the struct union is slower. See - [Platform Support](docs/wiki/Platform-Support.md#considered-and-rejected). + [Platform Support](docs/wiki/reference/Platform-Support.md#considered-and-rejected). - **`[Meter]` / `[MeterGeneration]` `Version` and `Tags`.** `MeterOptions.Version` and `MeterOptions.Tags` were previously reachable only through a `partial` hook the generator declared and never implemented, and `Version` was hard-coded `null`. Both are now attribute settings (comma-separated `key=value` tags, interface-level overriding @@ -71,7 +71,7 @@ and, for breaking changes, by [Breaking Changes](docs/wiki/Breaking-Changes.md). accessor per union type. When the union declares the non-boxing access pattern (`bool HasValue` plus a `bool TryGetValue(out T)` overload per case) the accessor calls those, so the union struct is never boxed to read it; otherwise it reads `Value`. A union that holds no value records `null`. - See [Union types](docs/wiki/Tags-and-Baggage.md#union-types). + See [Union types](docs/wiki/telemetry/Tags-and-Baggage.md#union-types). - **A lowest-compatible dependency floor, proved by a build.** `Directory.Packages.props` now keeps one property per package family (`DotnetRuntimeVersion` for the runtime-tracked `10.0.` line, `MSExtensionVersion` for dotnet/extensions' `10..0`) plus a `DependencyFloor` switch. `-p:DependencyFloor=true` drops both families to @@ -90,7 +90,7 @@ and, for breaking changes, by [Breaking Changes](docs/wiki/Breaking-Changes.md). `Unable to resolve service for type 'System.Diagnostics.Metrics.IMeterFactory'` on first resolution. The benchmark now passes a factory on every leg, and the sample registers one with `services.AddMetrics()` (plus `[assembly: MeterGeneration("sample-weather-app-net48")]` so its documented meter name is the one reported). - `docs/wiki/Metrics.md` and the [support matrix](docs/wiki/Platform-Support.md#support-matrix) said "not available on + `docs/wiki/telemetry/Metrics.md` and the [support matrix](docs/wiki/reference/Platform-Support.md#support-matrix) said "not available on .NET Framework 4.8"; they now describe the probe instead of a framework. - **The generator's two tagged unions are C# 15 `closed` hierarchies.** An activity method is exactly one of `StartActivityMethod`, `ActivityEventMethod` or `ActivityContextMethod`, and an instrument is exactly one of the eight @@ -111,7 +111,7 @@ and, for breaking changes, by [Breaking Changes](docs/wiki/Breaking-Changes.md). compiler-host assembly, so a transitive dependency is one it hands to every consumer's build. - **`.NET 11` opt-ins are the ones `Purview.BuildSdk` 2.0.0 already applies**, not new project properties: Runtime Async V2 (`PurviewRuntimeAsync`) and the C# 15 updated memory-safety rules (`MemorySafetyRules=2`) for every `net11.0` - project. `docs/wiki/Platform-Support.md` records each .NET 11 feature that was evaluated and why it is or is not used. + project. `docs/wiki/reference/Platform-Support.md` records each .NET 11 feature that was evaluated and why it is or is not used. - **`EventId` on `[Log]` and the level-specific attributes is `int`, not `int?`.** A nullable property cannot be set as a named attribute argument, so `[Info(EventId = 42)]` failed with CS0655 and only the constructor overloads worked. Omitting it, or passing a negative value, still uses the id derived @@ -206,7 +206,7 @@ exception is the opt-in `MeterNameGenerationType.OpenTelemetry` naming, which wa Register the factory with `services.AddTracing()`. The option is gated on the compilation resolving the type (.NET 11, or System.Diagnostics.DiagnosticSource 11+). When the type is unavailable, the static source is kept and the new warning **`TSG3023`** reports it. -- [Platform Support and .NET 11](docs/wiki/Platform-Support.md): the target-framework and hosting +- [Platform Support and .NET 11](docs/wiki/reference/Platform-Support.md): the target-framework and hosting matrix, the trim/AOT guarantees, the .NET 11 features and what is not supported yet. - The integration tests run on a `net11.0` leg. - The packed-consumer gate exercises `UseActivitySourceFactory` under Native AOT, Blazor diff --git a/README.md b/README.md index 0ba8a2ad..667227ed 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Generates [`ActivitySource`](https://learn.microsoft.com/en-us/dotnet/api/system **Hosting models:** console, ASP.NET Core, .NET Aspire, Native AOT, Blazor WebAssembly (trimmed and AOT-compiled) and Blazor Server. The generated code uses no reflection and adds no trim or AOT warnings, and no compiler-host assembly reaches your application's output. See -[Platform Support](https://purview.dev/docs/telemetry-sourcegenerator/platform-support/) for the full matrix. +[Platform Support](https://purview.dev/docs/telemetry-sourcegenerator/reference/platform-support/) for the full matrix. **Build toolchain requirement:** @@ -40,7 +40,7 @@ assembly reaches your application's output. See struct. **Purview.Unions `[GenerateUnion]` types** work too: the case list is read from the surface that is in source — the attribute's `typeof` list, partial static factories, a nested `IUnionMembers` provider, then the constructors — so no reference to that package is taken and no other generator's output is needed. See - [Union types](https://purview.dev/docs/telemetry-sourcegenerator/tags-and-baggage/#union-types). + [Union types](https://purview.dev/docs/telemetry-sourcegenerator/telemetry/tags-and-baggage/#union-types). - **Validated on .NET 11**: the tests run on `net11.0`, and Native AOT, Blazor WebAssembly and Blazor Server consumers of the package are built on every release. - **.NET 8 and .NET 9 are no longer supported**: target `net10.0`+ or `net48`+. `TSG1011` reports a @@ -172,16 +172,16 @@ public class EntityService(IEntityStoreTelemetry telemetry, IEntityRepository re | `[ObservableCounter]`, `[ObservableGauge]`, `[ObservableUpDownCounter]` | Method | Observable instruments | > [!TIP] -> For single-target interfaces (only Activities, only Logging, or only Metrics), the generator automatically infers the necessary attributes. See the [Multi-Targeting guide](https://purview.dev/docs/telemetry-sourcegenerator/multi-targeting/) for details. +> For single-target interfaces (only Activities, only Logging, or only Metrics), the generator automatically infers the necessary attributes. See the [Multi-Targeting guide](https://purview.dev/docs/telemetry-sourcegenerator/telemetry/multi-targeting/) for details. ## Documentation - [Homepage](https://purview.dev/projects/telemetry-sourcegenerator/) - [Documentation](https://purview.dev/docs/telemetry-sourcegenerator/) -- [Generated Output Examples](https://purview.dev/docs/telemetry-sourcegenerator/generated-output/) -- [Multi-Targeting Guide](https://purview.dev/docs/telemetry-sourcegenerator/multi-targeting/) -- [Logging Configuration](https://purview.dev/docs/telemetry-sourcegenerator/logging/) -- [Platform Support and .NET 11](https://purview.dev/docs/telemetry-sourcegenerator/platform-support/) +- [Generated Output Examples](https://purview.dev/docs/telemetry-sourcegenerator/reference/generated-output/) +- [Multi-Targeting Guide](https://purview.dev/docs/telemetry-sourcegenerator/telemetry/multi-targeting/) +- [Logging Configuration](https://purview.dev/docs/telemetry-sourcegenerator/telemetry/logging/) +- [Platform Support and .NET 11](https://purview.dev/docs/telemetry-sourcegenerator/reference/platform-support/) ## Agent Skills @@ -203,7 +203,7 @@ The [.NET Aspire Sample](https://github.com/purview-dev/telemetry-sourcegenerato ## Performance -Benchmarked on 13th Gen Intel Core i9-13900KF, .NET SDK 11.0.100. See the [Performance](https://purview.dev/docs/telemetry-sourcegenerator/performance/) page for the full cross-runtime results (.NET 10, .NET 11 and .NET Framework 4.8). Ratios below compare generated against the hand-written implementation of the same scenario. +Benchmarked on 13th Gen Intel Core i9-13900KF, .NET SDK 11.0.100. See the [Performance](https://purview.dev/docs/telemetry-sourcegenerator/reference/performance/) page for the full cross-runtime results (.NET 10, .NET 11 and .NET Framework 4.8). Ratios below compare generated against the hand-written implementation of the same scenario. ### Activities (.NET 10.0) @@ -329,7 +329,7 @@ with `MeterNameGenerationType.OpenTelemetry`, which also lowercases a meter name // The interface above now generates "my_app.products.product.record_count". ``` -See [Metrics](https://purview.dev/docs/telemetry-sourcegenerator/metrics/#meter-naming) for the full rules. +See [Metrics](https://purview.dev/docs/telemetry-sourcegenerator/telemetry/metrics/#meter-naming) for the full rules. #### Reverting to v3 Naming (Legacy Mode) @@ -364,7 +364,7 @@ public enum NamingConvention ## Contributing -Contributions are welcome! See the [Contributing guide](https://purview.dev/docs/telemetry-sourcegenerator/contributing/) for development setup and testing instructions. +Contributions are welcome! See the [Contributing guide](https://purview.dev/docs/telemetry-sourcegenerator/contributing/contributing/) for development setup and testing instructions. See [docs/release-process.md](docs/release-process.md) for the release flow, and [`AGENTS.md`](AGENTS.md) for the build, validation, and convention rules. Bump the version in `package.json` and run diff --git a/benchmarks/README.md b/benchmarks/README.md index 2197805d..632ef1b6 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -103,7 +103,7 @@ changes. This family is **contributor-facing**: it answers whether the record hierarchy's per-method allocation and virtual dispatch can move generator throughput, and it backs the recorded decision in -[Platform Support](../docs/wiki/Platform-Support.md#considered-and-rejected) to keep the `closed` records. It is not +[Platform Support](../docs/wiki/reference/Platform-Support.md#considered-and-rejected) to keep the `closed` records. It is not a consumer hot path and is not part of the README's consumer performance tables. ### `LoggerBenchmarks` diff --git a/docs/release-process.md b/docs/release-process.md index e76df165..d3187c8b 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -10,13 +10,15 @@ Releases are driven by GitHub Actions using the reusable [`purview-dev/build`](https://github.com/purview-dev/build) pipelines. No release steps are performed manually. -```text -Feature branch - → PR to main (pr.yml runs purview-build.yml: restore + build + lint + tests) - → merge to main (release.yml runs purview-release.yml: build, test, pack, publish) - → GitHub Release (NuGet package + changelog, created by the pipeline) + +```mermaid +flowchart LR + branch[Feature branch] --> pr[PR to main] --> merge[Merge to main] --> release[GitHub Release] ``` +[LikeC4 model](wiki/diagrams/release-flow.c4) + + ### Workflow components | Component | File | Purpose | @@ -74,7 +76,8 @@ Merging to `main` triggers `release.yml`, which runs the reusable `purview-relea - It is applied to `Version` / `PackageVersion` by `Purview.BuildSdk` via package.json version detection (`UsePackageJsonVersion`, default `true`). - `just version` prints the current version. -- After bumping `package.json`, run `just update-version` to sync the version into docs/samples. +- `just bump major|minor|patch` bumps `package.json`, then syncs the version into docs/samples itself + (through the `_after-bump` hook, which runs `just update-version`). ### Analyzer release tracking @@ -108,20 +111,19 @@ Before a release, validate locally on a clean tree. Each step must pass. and `RunAOTCompilation`) and Blazor Server consumers of the `.nupkg`, each leak-scanned for compiler-host assemblies. 4. `just build-s && just test-s` — sample solution build and tests. -5. `just lint` — CSharpier reports no change. +5. `just lint-check` — CSharpier reports no change. After changing `PackValidation`, also run `just verify-pack-rules` (pass `-PackagesFolder ` after `just pipeline-local-release`, which moves the package out of `artifacts/`). What each gate proves, and where CI runs it, is in the wiki's -[Release Flow — Release gates](wiki/Release-Flow.md#release-gates). +[Release Flow — Release gates](wiki/contributing/Release-Flow.md#release-gates). ## Releasing -1. Bump the version in `package.json`. -2. Run `just update-version` to sync docs/samples. -3. Push a PR with the version bump; merge to `main`. -4. `release.yml` publishes the release automatically. +1. `just bump major|minor|patch` — bumps `package.json` and syncs docs/samples. +2. Push a PR with the version bump; merge to `main`. +3. `release.yml` publishes the release automatically. > The legacy `scripts/setup-release.*` files describe a changeset-based (`@changesets/cli`) flow that > is **not** used by this repository. They are retained for reference only; the actual release diff --git a/docs/wiki/Generation.md b/docs/wiki/Generation.md index bc37e1e1..cc060372 100644 --- a/docs/wiki/Generation.md +++ b/docs/wiki/Generation.md @@ -55,7 +55,7 @@ public enum NamingConvention | --- | --- | --- | | ActivitySource name | Assembly name lowercased: `"myapp"` | Assembly name preserved: `"MyApp"` | | Tag / baggage keys | Lowercased, smashed: `"entityid"` | snake_case: `"entity_id"` | -| Metric instrument names | Lowercased, smashed: `"product.recordcount"` | Interface prefix + snake_case: `"product.record_count"` (see [Instrument naming](Metrics.md#instrument-naming)) | +| Metric instrument names | Lowercased, smashed: `"product.recordcount"` | Interface prefix + snake_case: `"product.record_count"` (see [Instrument naming](telemetry/Metrics.md#instrument-naming)) | | Metric tag keys | Lowercased, smashed: `"requestcount"` | snake_case: `"request_count"` | ```csharp @@ -128,7 +128,7 @@ The names are deduplicated and ordered, and the aggregate covers the whole refer The host project does not need telemetry of its own. An API or console project that defines no `[ActivitySource]` or `[Meter]` interface still gets the class from the assembly-level attribute alone, containing the names its references recorded. If neither the project nor its references generated any names, nothing is emitted. > [!NOTE] -> The reference direction still applies: an Aspire-style `ServiceDefaults` project is referenced *by* the projects that own the telemetry, so it cannot aggregate them. Generate the class in the host project and pass the arrays into `ServiceDefaults`, as the [sample application](Sample-Application.md) does. +> The reference direction still applies: an Aspire-style `ServiceDefaults` project is referenced *by* the projects that own the telemetry, so it cannot aggregate them. Generate the class in the host project and pass the arrays into `ServiceDefaults`, as the [sample application](guide/Sample-Application.md) does. Control the two outputs with `GenerateTelemetryNamesAttribute` (default `true`) and `GenerateTelemetryNamesClass` (default `false`), plus `TelemetryNamesClassName`, `TelemetryNamesNamespace`, and `AggregateReferencedTelemetryNames` for the class. @@ -156,10 +156,10 @@ interface IOrderServiceTelemetry | `[LoggerGeneration]` | Assembly-level default log level, default `LogPrefixType`, and default logging generation mode. | | `[MeterGeneration]` | Default meter name, meter-name generation type, instrument prefix/separator, and casing defaults. | -See [Activities](Activities.md), [Logging](Logging.md), and [Metrics](Metrics.md) for the full property tables. +See [Activities](telemetry/Activities.md), [Logging](telemetry/Logging.md), and [Metrics](telemetry/Metrics.md) for the full property tables. ## Next steps -- [Activities](Activities.md), [Logging](Logging.md), [Metrics](Metrics.md) — per-target attribute reference -- [Tags, Baggage, and Parameter Attributes](Tags-and-Baggage.md) — parameter-level attributes -- [Diagnostics](Diagnostics.md) — analyzer rules \ No newline at end of file +- [Activities](telemetry/Activities.md), [Logging](telemetry/Logging.md), [Metrics](telemetry/Metrics.md) — per-target attribute reference +- [Tags, Baggage, and Parameter Attributes](telemetry/Tags-and-Baggage.md) — parameter-level attributes +- [Diagnostics](reference/Diagnostics.md) — analyzer rules \ No newline at end of file diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index 31eb0598..cd016e9f 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -25,7 +25,7 @@ You may also need runtime dependencies depending on which telemetry types you us - `Microsoft.Extensions.Logging.Abstractions` for `ILogger` - `System.Diagnostics.Metrics` for metrics (built into .NET 10+) -See [Installation](Installation.md) for the full dependency matrix. +See [Installation](guide/Installation.md) for the full dependency matrix. ## Define a telemetry interface @@ -217,14 +217,14 @@ Generated files appear under `obj/Debug|Release//generated/Purview.Telemetr 3. **One namespace** — use the single `using Purview.Telemetry;` import. 4. **OpenTelemetry naming** — v5 defaults to OpenTelemetry conventions (snake_case tags, hierarchical metrics). See [Naming conventions](Generation.md#naming-conventions). 5. **DI registration** — use the generated `Add{InterfaceName}()` extension methods. -6. **Testing** — telemetry interfaces are easy to mock in unit tests. See [Testing](Testing.md). +6. **Testing** — telemetry interfaces are easy to mock in unit tests. See [Testing](guide/Testing.md). ## Next steps -- [Activities](Activities.md) — deep dive into distributed tracing with Activities, Events, and Context -- [Logging](Logging.md) — structured logging and the v1/v2 generation modes -- [Metrics](Metrics.md) — counters, histograms, and observable instruments -- [Multi-Targeting](Multi-Targeting.md) — combine multiple telemetry types in one interface +- [Activities](telemetry/Activities.md) — deep dive into distributed tracing with Activities, Events, and Context +- [Logging](telemetry/Logging.md) — structured logging and the v1/v2 generation modes +- [Metrics](telemetry/Metrics.md) — counters, histograms, and observable instruments +- [Multi-Targeting](telemetry/Multi-Targeting.md) — combine multiple telemetry types in one interface - [Generation Options](Generation.md) — control code generation, DI, and naming -- [Generated Output](Generated-Output.md) — see what code is actually generated -- [Migration](Refactorings.md) — convert existing `ILogger`/`ActivitySource`/metrics code using the IDE refactorings +- [Generated Output](reference/Generated-Output.md) — see what code is actually generated +- [Migration](reference/Refactorings.md) — convert existing `ILogger`/`ActivitySource`/metrics code using the IDE refactorings diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 62b19c89..9cbf1907 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -16,59 +16,59 @@ This approach allows for: - .NET Framework 4.8 or higher .NET 8 and .NET 9 are no longer supported. A project that targets either reports -[`TSG1011`](Diagnostics.md#tsg1011). +[`TSG1011`](reference/Diagnostics.md#tsg1011). **Build toolchain requirement:** Visual Studio 2026 (18.x) or the .NET 10 SDK or later (Roslyn 5.9.0+). Native AOT, trimmed and AOT-compiled Blazor WebAssembly, and Blazor Server are supported. See -[Platform Support and .NET 11](Platform-Support.md) for the full matrix, the trim/AOT guarantees and what's new for .NET 11. +[Platform Support and .NET 11](reference/Platform-Support.md) for the full matrix, the trim/AOT guarantees and what's new for .NET 11. ## Documentation ### Getting started - [Getting Started](Getting-Started.md) — install, first interface, and common patterns -- [Installation](Installation.md) — supported frameworks, per-feature dependencies, and build configuration -- [Platform Support and .NET 11](Platform-Support.md) — support matrix, trim/AOT guarantees, .NET 11 features -- [Sample Application](Sample-Application.md) — full .NET Aspire demo application +- [Installation](guide/Installation.md) — supported frameworks, per-feature dependencies, and build configuration +- [Platform Support and .NET 11](reference/Platform-Support.md) — support matrix, trim/AOT guarantees, .NET 11 features +- [Sample Application](guide/Sample-Application.md) — full .NET Aspire demo application ### Core features -- [Activities](Activities.md) — distributed tracing generation with `ActivitySource` -- [Logging](Logging.md) — structured logging generation with `ILogger` - - [Generation v2](Logging-Generation-v2.md) — state-based logging with `Microsoft.Extensions.Telemetry.Abstractions` - - [Generation v1](Logging-Generation-v1.md) — legacy `LoggerMessage.Define` high-performance mode -- [Metrics](Metrics.md) — metrics generation (Counters, Histograms, Observables) -- [Multi-Targeting](Multi-Targeting.md) — combine Activities + Logging + Metrics in one interface +- [Activities](telemetry/Activities.md) — distributed tracing generation with `ActivitySource` +- [Logging](telemetry/Logging.md) — structured logging generation with `ILogger` + - [Generation v2](telemetry/Logging-Generation-v2.md) — state-based logging with `Microsoft.Extensions.Telemetry.Abstractions` + - [Generation v1](telemetry/Logging-Generation-v1.md) — legacy `LoggerMessage.Define` high-performance mode +- [Metrics](telemetry/Metrics.md) — metrics generation (Counters, Histograms, Observables) +- [Multi-Targeting](telemetry/Multi-Targeting.md) — combine Activities + Logging + Metrics in one interface ### Configuration and reference - [Generation Options](Generation.md) — control class names, DI, and code generation -- [Tags, Baggage, and Parameter Attributes](Tags-and-Baggage.md) — adding tags/baggage/properties to telemetry -- [Generated Output](Generated-Output.md) — examples of generated code -- [Diagnostics](Diagnostics.md) — analyzer warnings and errors -- [FAQ](FAQ.md) — frequently asked questions and troubleshooting -- [Breaking Changes](Breaking-Changes.md) — migration guide for v3 → v4 → v5 -- [Performance](Performance.md) — cross-runtime benchmark results +- [Tags, Baggage, and Parameter Attributes](telemetry/Tags-and-Baggage.md) — adding tags/baggage/properties to telemetry +- [Generated Output](reference/Generated-Output.md) — examples of generated code +- [Diagnostics](reference/Diagnostics.md) — analyzer warnings and errors +- [FAQ](reference/FAQ.md) — frequently asked questions and troubleshooting +- [Breaking Changes](reference/Breaking-Changes.md) — migration guide for v3 → v4 → v5 +- [Performance](reference/Performance.md) — cross-runtime benchmark results ### Migrating -- [Refactorings](Refactorings.md) — the shipped IDE code refactorings -- [Migration from ILogger](Migration-From-ILogger.md) -- [Migration from Activities](Migration-From-Activities.md) +- [Refactorings](reference/Refactorings.md) — the shipped IDE code refactorings +- [Migration from ILogger](migration/Migration-From-ILogger.md) +- [Migration from Activities](migration/Migration-From-Activities.md) ### Contributing -- [Testing](Testing.md) — mocking generated telemetry interfaces -- [Contributing](Contributing.md) — development setup, build commands, and conventions -- [Release Flow](Release-Flow.md) — how releases are produced +- [Testing](guide/Testing.md) — mocking generated telemetry interfaces +- [Contributing](contributing/Contributing.md) — development setup, build commands, and conventions +- [Release Flow](contributing/Release-Flow.md) — how releases are produced ## Basic examples -Each generation target ([Activities](Activities.md), [Logging](Logging.md), and [Metrics](Metrics.md)) documents what can be inferred and what must be explicit. By default each interface used as a source for generation includes an extension method for registering it with an `IServiceCollection`; more details can be found in [Generation](Generation.md). +Each generation target ([Activities](telemetry/Activities.md), [Logging](telemetry/Logging.md), and [Metrics](telemetry/Metrics.md)) documents what can be inferred and what must be explicit. By default each interface used as a source for generation includes an extension method for registering it with an `IServiceCollection`; more details can be found in [Generation](Generation.md). > [!TIP] -> You can mix-and-match generation targets within a single interface; this is called [multi-targeting](Multi-Targeting.md). When you do, inference is disabled and every method must declare its targets explicitly. +> You can mix-and-match generation targets within a single interface; this is called [multi-targeting](telemetry/Multi-Targeting.md). When you do, inference is disabled and every method must declare its targets explicitly. > [!NOTE] > In .NET, Activities, Events, and Metrics capture additional properties at creation, recording, or observation time as **tags**. OpenTelemetry calls these **attributes**. Because this source generator makes extensive use of marker attributes to control code generation, these docs use *tags* for the properties and *attributes* for the .NET [`Attribute`](https://learn.microsoft.com/en-us/dotnet/api/system.attribute) type. @@ -105,7 +105,7 @@ interface IActivityTelemetry } ``` -More information can be found in [Activities](Activities.md). +More information can be found in [Activities](telemetry/Activities.md). ### Logging @@ -131,7 +131,7 @@ interface ILoggingTelemetry } ``` -More information can be found in [Logging](Logging.md), including the two generation modes and how to disable logging generation when the `Microsoft.Extensions.Logging` types are unavailable. +More information can be found in [Logging](telemetry/Logging.md), including the two generation modes and how to disable logging generation when the `Microsoft.Extensions.Logging` types are unavailable. ### Metrics @@ -181,7 +181,7 @@ interface IMeterTelemetry } ``` -More information can be found in [Metrics](Metrics.md). +More information can be found in [Metrics](telemetry/Metrics.md). ## Multi-targeting @@ -223,4 +223,4 @@ interface IServiceTelemetry } ``` -More information can be found in [Multi-Targeting](Multi-Targeting.md). \ No newline at end of file +More information can be found in [Multi-Targeting](telemetry/Multi-Targeting.md). \ No newline at end of file diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 9f5302dd..59ca73e0 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -1,24 +1,29 @@ - [Home](Home.md) - [Getting Started](Getting-Started.md) -- [Installation](Installation.md) -- [Platform Support and .NET 11](Platform-Support.md) - [Generation](Generation.md) -- [Activities](Activities.md) -- [Logging](Logging.md) -- [Logging Generation v1](Logging-Generation-v1.md) -- [Logging Generation v2](Logging-Generation-v2.md) -- [Metrics](Metrics.md) -- [Multi-Targeting](Multi-Targeting.md) -- [Tags, Baggage, and Parameter Attributes](Tags-and-Baggage.md) -- [Generated Output](Generated-Output.md) -- [Diagnostics](Diagnostics.md) -- [Performance](Performance.md) -- [FAQ](FAQ.md) -- [Breaking Changes](Breaking-Changes.md) -- [Refactorings](Refactorings.md) -- [Migration from ILogger](Migration-From-ILogger.md) -- [Migration from Activities](Migration-From-Activities.md) -- [Testing](Testing.md) -- [Sample Application](Sample-Application.md) -- [Contributing](Contributing.md) -- [Release Flow](Release-Flow.md) \ No newline at end of file +- **Guide** + - [Installation](guide/Installation.md) + - [Sample Application](guide/Sample-Application.md) + - [Testing](guide/Testing.md) +- **Telemetry** + - [Activities](telemetry/Activities.md) + - [Logging](telemetry/Logging.md) + - [Logging Generation v1](telemetry/Logging-Generation-v1.md) + - [Logging Generation v2](telemetry/Logging-Generation-v2.md) + - [Metrics](telemetry/Metrics.md) + - [Multi-Targeting](telemetry/Multi-Targeting.md) + - [Tags, Baggage, and Parameter Attributes](telemetry/Tags-and-Baggage.md) +- **Reference** + - [Diagnostics](reference/Diagnostics.md) + - [Generated Output](reference/Generated-Output.md) + - [Refactorings](reference/Refactorings.md) + - [Breaking Changes](reference/Breaking-Changes.md) + - [Platform Support and .NET 11](reference/Platform-Support.md) + - [Performance](reference/Performance.md) + - [FAQ](reference/FAQ.md) +- **Migration** + - [From ILogger](migration/Migration-From-ILogger.md) + - [From Activities](migration/Migration-From-Activities.md) +- **Contributing** + - [Contributing](contributing/Contributing.md) + - [Release Flow](contributing/Release-Flow.md) diff --git a/docs/wiki/_redirects.yml b/docs/wiki/_redirects.yml new file mode 100644 index 00000000..4ab350a9 --- /dev/null +++ b/docs/wiki/_redirects.yml @@ -0,0 +1,33 @@ +# previous: current +# +# Every page below moved into a topic folder, so its URL gained a segment: +# /docs/telemetry-sourcegenerator// -> /docs/telemetry-sourcegenerator///. +# The site writes a noindex redirect at the old address, and only once the release +# that carries the move is published on the live channel. Never delete a released +# pair; point a page that moves again at its final address. +redirects: + installation: guide/installation + sample-application: guide/sample-application + testing: guide/testing + + activities: telemetry/activities + logging: telemetry/logging + logging-generation-v1: telemetry/logging-generation-v1 + logging-generation-v2: telemetry/logging-generation-v2 + metrics: telemetry/metrics + multi-targeting: telemetry/multi-targeting + tags-and-baggage: telemetry/tags-and-baggage + + diagnostics: reference/diagnostics + generated-output: reference/generated-output + refactorings: reference/refactorings + breaking-changes: reference/breaking-changes + platform-support: reference/platform-support + performance: reference/performance + faq: reference/faq + + migration-from-ilogger: migration/migration-from-ilogger + migration-from-activities: migration/migration-from-activities + + contributing: contributing/contributing + release-flow: contributing/release-flow diff --git a/docs/wiki/contributing/Contributing.md b/docs/wiki/contributing/Contributing.md index 76aeb72a..85d4ce74 100644 --- a/docs/wiki/contributing/Contributing.md +++ b/docs/wiki/contributing/Contributing.md @@ -16,7 +16,7 @@ git clone https://github.com/purview-dev/telemetry-sourcegenerator cd telemetry-sourcegenerator ``` -All `just` recipes read the [`Justfile`](https://github.com/purview-dev/telemetry-sourcegenerator/blob/main/Justfile); configuration defaults to **Debug**. +All `just` recipes read the [`Justfile`](https://github.com/purview-dev/telemetry-sourcegenerator/blob/main/Justfile); configuration defaults to **Debug**. The shared recipes are the modules under `.build/just/` (copied from the `purview-buildsdk-justfile` skill and not edited here); the `Justfile` holds the repository's own recipes and overrides. ## Common commands @@ -26,7 +26,7 @@ All `just` recipes read the [`Justfile`](https://github.com/purview-dev/telemetr | `just test` | Runs the integration tests (Debug) with a tree-node filter. | | `just build-s` | Builds `samples/SampleApp/SampleApp.slnx`. | | `just test-s` | Runs the sample test solution. | -| `just lint` | Runs `dotnet csharpier check .` (no writes). | +| `just lint-check` | Runs `dotnet csharpier check .` (no writes). | | `just lint-fix` | Runs `dotnet csharpier format .` (writes). | | `just clean` | Cleans the main solution. | | `just restore` | Restores packages for the main solution. | @@ -39,7 +39,7 @@ After making changes to the source generator: 1. `just build` — build the main solution. 2. `just test` — run the integration tests. 3. `just build-s && just test-s` — build and test the sample application end to end. -4. `just lint` / `just format` — ensure C# formatting compliance before committing. +4. `just lint-check` / `just format` — ensure C# formatting compliance before committing. > [!NOTE] > Build and test commands can take a long time (tens of minutes) in slow environments. Give them a generous timeout and never cancel them part-way. @@ -53,7 +53,7 @@ When generator behaviour changes, add or update tests in `src/tests/SourceGenera ## Conventions - **Conventional commits** — enforced by commitlint (`.config/lefthook.yml`). Types: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`. -- **Formatting** — C# is formatted with csharpier; check with `just lint`. +- **Formatting** — C# is formatted with csharpier; check with `just lint-check`. - **Solutions** — `.slnx` solution files are used. - **Source generator** — targets `netstandard2.0` and uses `Purview.SourceGeneratorFramework` (CodeWriter-based emission, incremental pipeline, value-equatable models). @@ -75,9 +75,9 @@ Before doing specialist work, load the relevant skill from `.agents/skills/` — ## Release process -See [`docs/release-process.md`](../release-process.md) for the full flow. In short: push a feature branch and open a PR to `main` (`pr.yml` runs build + lint + tests); merging to `main` triggers the reusable release pipeline (build, test, pack, publish, GitHub release). The version lives in `package.json`; after bumping it, run `just update-version` to sync docs/samples, then `just pipeline-local-release` to validate locally. +See [`docs/release-process.md`](../../release-process.md) for the full flow. In short: push a feature branch and open a PR to `main` (`pr.yml` runs build + lint + tests); merging to `main` triggers the reusable release pipeline (build, test, pack, publish, GitHub release). The version lives in `package.json`; `just bump major|minor|patch` bumps it and syncs docs/samples, then `just pipeline-local-release` validates locally. ## Next steps - [Release Flow](Release-Flow.md) — how releases are produced -- [Testing](Testing.md) — mocking generated telemetry interfaces \ No newline at end of file +- [Testing](../guide/Testing.md) — mocking generated telemetry interfaces \ No newline at end of file diff --git a/docs/wiki/contributing/Release-Flow.md b/docs/wiki/contributing/Release-Flow.md index 90f8c017..6f6e1ceb 100644 --- a/docs/wiki/contributing/Release-Flow.md +++ b/docs/wiki/contributing/Release-Flow.md @@ -2,13 +2,15 @@ Releases are driven by GitHub Actions using the reusable [`purview-dev/build`](https://github.com/purview-dev/build) pipelines. No release steps are performed manually. -```text -Feature branch - → PR to main (pr.yml runs purview-build.yml: restore + build + lint + tests) - → merge to main (release.yml runs purview-release.yml: build, test, pack, publish) - → GitHub Release (NuGet package + changelog, created by the pipeline) + +```mermaid +flowchart LR + branch[Feature branch] --> pr[PR to main] --> merge[Merge to main] --> release[GitHub Release] ``` +[LikeC4 model](../diagrams/release-flow.c4) + + ## Workflow components | Component | File | Purpose | @@ -30,7 +32,8 @@ Feature branch - The version lives in `package.json`. **Current Version:** `*` (run `just version` to print it) - It is applied to `Version`/`PackageVersion` by `Purview.BuildSdk` via package.json version detection. - `just version` prints the current version. -- After bumping `package.json`, run `just update-version` to sync the version into docs/samples. +- `just bump major|minor|patch` bumps `package.json`, then syncs the version into docs/samples itself + (through the `_after-bump` hook, which runs `just update-version`). ## Building the package locally @@ -48,7 +51,7 @@ check fails rather than passing. | Gate | What it proves | Local | CI | | --- | --- | --- | --- | -| Build, lint and tests | The solution builds warning-free, CSharpier reports no change, and every test project passes. | `just build`, `just lint`, `just test` | `pr.yml` `build` | +| Build, lint and tests | The solution builds warning-free, CSharpier reports no change, and every test project passes. | `just build`, `just lint-check`, `just test` | `pr.yml` `build` | | Pack and pack validation | The package packs; `purview-build.json` `PackValidation` finds exactly the declared content (`RequiredContent`, `RequireExplicitContent`) and none of the `ForbiddenContent`. | `just pipeline-pack-validate` (which also runs the rule proof) | `pr.yml` `build` | | Forbidden-content rule proof | Every `ForbiddenContent` rule rejects a package: a matching file is injected into scratch copies of the package and the real `purview-build` validation must fail, per package and per rule. | `just verify-pack-rules` (and the end of `just pipeline-pack-validate`) | `pr.yml` `pack-validation`, in parallel with the consumer gate | | Dependency floor | Every declared dependency version is a lower bound, so the floor this package declares becomes the floor every consumer inherits. `-p:DependencyFloor=true` drops each package family to the oldest version the generator's code and tests compile and pass against, while the ordinary leg resolves the latest servicing patch of the same line. The two together prove the bottom and the top. | `just test-floor` | `pr.yml` `dependency-floor` | @@ -87,7 +90,7 @@ just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/ # restore, build, lint, tests, pack, pack validation, local publish just smoke-packed-consumers # Native AOT, Blazor WebAssembly (trimmed and AOT) and Blazor Server just build-s && just test-s # sample solution build and tests -just lint # CSharpier reports no change +just lint-check # CSharpier reports no change ``` `just verify-pack-rules` re-proves the forbidden-content rules against the package in `artifacts/` (after @@ -96,10 +99,9 @@ just lint # CSharpier reports no change ## Releasing -1. Bump the version in `package.json`. -2. Run `just update-version` to sync docs/samples. -3. Push a PR with the version bump; merge to `main`. -4. `release.yml` publishes the release automatically. +1. `just bump major|minor|patch` — bumps `package.json` and syncs docs/samples. +2. Push a PR with the version bump; merge to `main`. +3. `release.yml` publishes the release automatically. > The legacy `scripts/setup-release.*` files describe a changeset-based (`@changesets/cli`) flow that is **not** used by this repository. They are retained for reference only; the actual release automation lives in `.github/workflows/pr.yml` and `.github/workflows/release.yml`. diff --git a/docs/wiki/diagrams/release-flow.c4 b/docs/wiki/diagrams/release-flow.c4 new file mode 100644 index 00000000..3928779a --- /dev/null +++ b/docs/wiki/diagrams/release-flow.c4 @@ -0,0 +1,40 @@ +specification { + element stage { + style { + shape rectangle + } + } + element delivery { + style { + shape rectangle + color secondary + } + } +} + +model { + branch = stage 'Feature branch' { + description 'A change on its own branch' + } + pr = stage 'PR to main' { + description 'pr.yml runs purview-build.yml: restore, build, lint, tests' + } + merge = stage 'Merge to main' { + description 'release.yml runs purview-release.yml: build, test, pack, publish' + } + release = delivery 'GitHub Release' { + description 'The NuGet package and the changelog, created by the pipeline' + } + + branch -> pr + pr -> merge + merge -> release +} + +views { + view releaseFlow { + title 'Release flow' + include * + autoLayout LeftRight + } +} diff --git a/docs/wiki/guide/Installation.md b/docs/wiki/guide/Installation.md index a1996e29..cdfcf22a 100644 --- a/docs/wiki/guide/Installation.md +++ b/docs/wiki/guide/Installation.md @@ -13,7 +13,7 @@ This page covers supported frameworks, installation methods, per-feature depende - Visual Studio 2026 (18.x) or the .NET 10 SDK or later (Roslyn 5.9.0+) -The generator itself is a Roslyn component targeting `netstandard2.0`, so it works in any supported compiler host. See [Platform Support and .NET 11](Platform-Support.md) for the hosting models (Native AOT, Blazor WebAssembly, Blazor Server, Aspire) and the trim/AOT guarantees. +The generator itself is a Roslyn component targeting `netstandard2.0`, so it works in any supported compiler host. See [Platform Support and .NET 11](../reference/Platform-Support.md) for the hosting models (Native AOT, Blazor WebAssembly, Blazor Server, Aspire) and the trim/AOT guarantees. ## Install the package @@ -48,7 +48,7 @@ Install-Package Purview.Telemetry.SourceGenerator | Logging | `Microsoft.Extensions.Logging.Abstractions` | Required for `[Logger]` interfaces; `TSG2003` is raised if `ILogger` cannot be resolved. Part of the shared framework on .NET 11, so no reference is needed there (an explicit one reports `NU1510`) | | Logging (v2) | `Microsoft.Extensions.Telemetry.Abstractions` | Required for state-based v2 generation (`LoggerMessageHelper`, `[LogProperties]`) | | Metrics | `System.Diagnostics.Metrics` | Included in the SDK for .NET 10+ | -| Activities from `ActivitySourceFactory` (opt-in) | `Microsoft.Extensions.Diagnostics` 11 (`services.AddTracing()`) | Only with `UseActivitySourceFactory`; see [Activities](Activities.md#creating-sources-with-activitysourcefactory-net-11) | +| Activities from `ActivitySourceFactory` (opt-in) | `Microsoft.Extensions.Diagnostics` 11 (`services.AddTracing()`) | Only with `UseActivitySourceFactory`; see [Activities](../telemetry/Activities.md#creating-sources-with-activitysourcefactory-net-11) | ## Verifying the install @@ -100,7 +100,7 @@ Define this constant to disable logging generation entirely. This is useful when ### Unsupported target frameworks A compilation that targets neither `net10.0`+ nor `net48`+ (for example `netstandard2.0`, `net8.0` or -`net9.0`) reports [`TSG1011`](Diagnostics.md#tsg1011) and generates nothing. No define suppresses it. A +`net9.0`) reports [`TSG1011`](../reference/Diagnostics.md#tsg1011) and generates nothing. No define suppresses it. A library that keeps such a target references the package only from its supported targets, and puts its telemetry interfaces under the same condition: @@ -171,6 +171,6 @@ See the [sample application](Sample-Application.md) for a complete Aspire setup ## Next steps -- [Getting Started](Getting-Started.md) — write your first telemetry interface -- [Generation Options](Generation.md) — control class names, DI, and naming -- [Diagnostics](Diagnostics.md) — analyzer warnings and errors \ No newline at end of file +- [Getting Started](../Getting-Started.md) — write your first telemetry interface +- [Generation Options](../Generation.md) — control class names, DI, and naming +- [Diagnostics](../reference/Diagnostics.md) — analyzer warnings and errors \ No newline at end of file diff --git a/docs/wiki/guide/Sample-Application.md b/docs/wiki/guide/Sample-Application.md index e55cda2e..5713b1cc 100644 --- a/docs/wiki/guide/Sample-Application.md +++ b/docs/wiki/guide/Sample-Application.md @@ -15,7 +15,7 @@ The [.NET Aspire sample](https://github.com/purview-dev/telemetry-sourcegenerato The sample targets `net10.0` and enables `EmitCompilerGeneratedFiles`, so generated output can be inspected under: -``` +```text obj//net10.0/generated/Purview.Telemetry.SourceGenerator/Purview.Telemetry.SourceGenerator.TelemetrySourceGenerator/ ``` @@ -162,7 +162,7 @@ public static readonly string[] ActivitySourceNames = new string[] { "SampleApp. `SampleApp.Web` references `SampleApp.Shared` for the DTOs only, so it aggregates the same names without emitting from them — the aggregate follows the reference graph, not usage, and registering a source that stays quiet costs nothing. -See [Generation](Generation.md#telemetry-names). +See [Generation](../Generation.md#telemetry-names). ## Multi-project sample @@ -189,6 +189,6 @@ With `dotnet run --project samples/SampleApp/SampleApp.AppHost`, the Aspire Dash ## See also -- [Getting Started](Getting-Started.md) -- [Multi-Targeting](Multi-Targeting.md) -- [Generated Output](Generated-Output.md) — real generated code from this sample \ No newline at end of file +- [Getting Started](../Getting-Started.md) +- [Multi-Targeting](../telemetry/Multi-Targeting.md) +- [Generated Output](../reference/Generated-Output.md) — real generated code from this sample \ No newline at end of file diff --git a/docs/wiki/guide/Testing.md b/docs/wiki/guide/Testing.md index 8aecde2c..90ce144c 100644 --- a/docs/wiki/guide/Testing.md +++ b/docs/wiki/guide/Testing.md @@ -63,4 +63,4 @@ For full multi-target scenarios, the [sample application](Sample-Application.md) ## Integration tests in this repository -The repository's own test suite lives in `src/tests/SourceGenerator.IntegrationTests` and uses `Purview.SourceGeneratorFramework.Testing.TUnit` (TUnit with the framework's `CodeQuery` syntax-lookup API and assertion extensions). See [Contributing](Contributing.md) for running them. \ No newline at end of file +The repository's own test suite lives in `src/tests/SourceGenerator.IntegrationTests` and uses `Purview.SourceGeneratorFramework.Testing.TUnit` (TUnit with the framework's `CodeQuery` syntax-lookup API and assertion extensions). See [Contributing](../contributing/Contributing.md) for running them. \ No newline at end of file diff --git a/docs/wiki/migration/Migration-From-Activities.md b/docs/wiki/migration/Migration-From-Activities.md index 12519e73..4f492e1d 100644 --- a/docs/wiki/migration/Migration-From-Activities.md +++ b/docs/wiki/migration/Migration-From-Activities.md @@ -1,6 +1,6 @@ # Migration from Activities -This guide covers converting hand-written `ActivitySource`/`Activity` code to generated telemetry interfaces. For the fastest path, use the [IDE refactorings](Refactorings.md) — this page documents the manual fallback. +This guide covers converting hand-written `ActivitySource`/`Activity` code to generated telemetry interfaces. For the fastest path, use the [IDE refactorings](../reference/Refactorings.md) — this page documents the manual fallback. ## Before @@ -68,7 +68,7 @@ services.AddOrderServiceTracing(); ## Tags and baggage -Use `[Tag]` and `[Baggage]` on parameters to control how values are attached — see [Tags, Baggage, and Parameter Attributes](Tags-and-Baggage.md). +Use `[Tag]` and `[Baggage]` on parameters to control how values are attached — see [Tags, Baggage, and Parameter Attributes](../telemetry/Tags-and-Baggage.md). ## Naming @@ -77,13 +77,13 @@ The ActivitySource name defaults to the assembly name with casing preserved. Set ## Checklist 1. Add the `Purview.Telemetry.SourceGenerator` package. -2. Create the `[ActivitySource]` interface (or run the [ActivitySource refactoring](Refactorings.md#convert-activitysource-to-iclassnametracing)). +2. Create the `[ActivitySource]` interface (or run the [ActivitySource refactoring](../reference/Refactorings.md#convert-activitysource-to-iclassnametracing)). 3. Replace `ActivitySource` fields and `StartActivity`/`AddEvent`/`SetTag`/`SetBaggage` calls with the interface. 4. Register with `services.Add{InterfaceNameWithoutI}()`. -5. Rebuild and review the [Diagnostics](Diagnostics.md). +5. Rebuild and review the [Diagnostics](../reference/Diagnostics.md). ## Next steps -- [Refactorings](Refactorings.md) — the shipped IDE conversions -- [Activities](Activities.md) — full attribute reference -- [Multi-Targeting](Multi-Targeting.md) — combine with Logging and Metrics \ No newline at end of file +- [Refactorings](../reference/Refactorings.md) — the shipped IDE conversions +- [Activities](../telemetry/Activities.md) — full attribute reference +- [Multi-Targeting](../telemetry/Multi-Targeting.md) — combine with Logging and Metrics \ No newline at end of file diff --git a/docs/wiki/migration/Migration-From-ILogger.md b/docs/wiki/migration/Migration-From-ILogger.md index bc7ad6b5..0f7053bc 100644 --- a/docs/wiki/migration/Migration-From-ILogger.md +++ b/docs/wiki/migration/Migration-From-ILogger.md @@ -1,6 +1,6 @@ # Migration from ILogger -This guide covers converting hand-written `ILogger` usage to generated telemetry interfaces. For the fastest path, use the [IDE refactorings](Refactorings.md) — this page documents the manual fallback. +This guide covers converting hand-written `ILogger` usage to generated telemetry interfaces. For the fastest path, use the [IDE refactorings](../reference/Refactorings.md) — this page documents the manual fallback. ## Before @@ -103,22 +103,22 @@ logger.OrderFailed(orderId, ex); ## Structured data -For collections, use `[ExpandEnumerable]`; for objects, use `[LogProperties]` — see [Logging Generation v2](Logging-Generation-v2.md). +For collections, use `[ExpandEnumerable]`; for objects, use `[LogProperties]` — see [Logging Generation v2](../telemetry/Logging-Generation-v2.md). ## Naming -v4+ converts parameter names to snake_case by default (OpenTelemetry convention). To revert to v3 naming, apply `[assembly: TelemetryGeneration(NamingConvention = NamingConvention.Legacy)]`. See [Naming conventions](Generation.md#naming-conventions). +v4+ converts parameter names to snake_case by default (OpenTelemetry convention). To revert to v3 naming, apply `[assembly: TelemetryGeneration(NamingConvention = NamingConvention.Legacy)]`. See [Naming conventions](../Generation.md#naming-conventions). ## Checklist 1. Add the `Purview.Telemetry.SourceGenerator` package and `Microsoft.Extensions.Logging.Abstractions`. -2. Create the `[Logger]` interface (or run the [ILogger refactoring](Refactorings.md#convert-ilogger-to-iclassnamelogs)). +2. Create the `[Logger]` interface (or run the [ILogger refactoring](../reference/Refactorings.md#convert-ilogger-to-iclassnamelogs)). 3. Replace `ILogger` members and calls with the interface. 4. Register with `services.Add{InterfaceNameWithoutI}()`. -5. Rebuild and review the [Diagnostics](Diagnostics.md). +5. Rebuild and review the [Diagnostics](../reference/Diagnostics.md). ## Next steps -- [Refactorings](Refactorings.md) — the shipped IDE conversions -- [Logging](Logging.md) — v1/v2 generation modes -- [Multi-Targeting](Multi-Targeting.md) — combine with Activities and Metrics \ No newline at end of file +- [Refactorings](../reference/Refactorings.md) — the shipped IDE conversions +- [Logging](../telemetry/Logging.md) — v1/v2 generation modes +- [Multi-Targeting](../telemetry/Multi-Targeting.md) — combine with Activities and Metrics \ No newline at end of file diff --git a/docs/wiki/reference/Breaking-Changes.md b/docs/wiki/reference/Breaking-Changes.md index 3e881880..d2726786 100644 --- a/docs/wiki/reference/Breaking-Changes.md +++ b/docs/wiki/reference/Breaking-Changes.md @@ -146,7 +146,7 @@ If a project uses `TelemetryNames.MeterNames` / `TelemetryNames.ActivitySourceNa [assembly: TelemetryGeneration(GenerateTelemetryNamesClass = true)] ``` -The class it generates now also includes the names recorded by referenced assemblies, so a host project no longer needs one `TelemetryNames` reference per library. Set `AggregateReferencedTelemetryNames = false` for the previous assembly-only behaviour. See [Generation](Generation.md#telemetry-names). +The class it generates now also includes the names recorded by referenced assemblies, so a host project no longer needs one `TelemetryNames` reference per library. Set `AggregateReferencedTelemetryNames = false` for the previous assembly-only behaviour. See [Generation](../Generation.md#telemetry-names). ## v4 to v5 @@ -171,7 +171,7 @@ interface ICacheServiceTelemetry { } ### `MeterGenerationAttribute` additions -v5 adds `MeterName` and `MeterNameGenerationType` to `[MeterGeneration]`, controlling the default meter name and whether instrument names are prefixed with the meter name. See [Metrics](Metrics.md#metergeneration). +v5 adds `MeterName` and `MeterNameGenerationType` to `[MeterGeneration]`, controlling the default meter name and whether instrument names are prefixed with the meter name. See [Metrics](../telemetry/Metrics.md#metergeneration). ### Activity return type should be nullable diff --git a/docs/wiki/reference/Diagnostics.md b/docs/wiki/reference/Diagnostics.md index f5442c8b..17c55c7e 100644 --- a/docs/wiki/reference/Diagnostics.md +++ b/docs/wiki/reference/Diagnostics.md @@ -156,7 +156,7 @@ The method is emitted as a throwing stub, so the interface stays implemented, wh ### TSG2024 / TSG2025 / TSG2026 — `[TagProvider]` cannot be used `[TagProvider(typeof(T), "Method")]` names a static method that contributes extra tags; see -[Logging](Logging.md#tagprovider-contributed-tags). `TSG2024` reports a provider type that cannot be resolved, +[Logging](../telemetry/Logging.md#tagprovider-contributed-tags). `TSG2024` reports a provider type that cannot be resolved, `TSG2025` a method that does not exist on it, and `TSG2026` a method whose signature the generated `foreach` cannot read. In every case the parameter is still logged — only the extra tags are missing. @@ -201,7 +201,7 @@ Return `Activity?` so callers can handle the `null` case when no listeners are a `[assembly: ActivitySourceGeneration(..., UseActivitySourceFactory = true)]` asks for sources created by `System.Diagnostics.ActivitySourceFactory`, but the compilation cannot resolve that type, so a static `ActivitySource` is generated instead. Target `net11.0`, or reference System.Diagnostics.DiagnosticSource 11 or later; or remove the -option. See [Creating sources with ActivitySourceFactory](Activities.md#creating-sources-with-activitysourcefactory-net-11). +option. See [Creating sources with ActivitySourceFactory](../telemetry/Activities.md#creating-sources-with-activitysourcefactory-net-11). ### TSG3024 — ActivitySourceOptions require UseActivitySourceFactory @@ -209,7 +209,7 @@ option. See [Creating sources with ActivitySourceFactory](Activities.md#creating `System.Diagnostics.ActivitySourceOptions`, which a factory-created source passes to `ActivitySourceFactory.Create`. A static `ActivitySource` has nowhere to put them, so they are ignored. Set `UseActivitySourceFactory = true`, or remove the option. See -[Creating sources with ActivitySourceFactory](Activities.md#creating-sources-with-activitysourcefactory-net-11). +[Creating sources with ActivitySourceFactory](../telemetry/Activities.md#creating-sources-with-activitysourcefactory-net-11). ### TSG4002 — auto-increment counter with a measurement @@ -217,6 +217,6 @@ option. See [Creating sources with ActivitySourceFactory](Activities.md#creating ## See also -- [Getting Started](Getting-Started.md) -- [Activities](Activities.md), [Logging](Logging.md), [Metrics](Metrics.md) -- [Multi-Targeting](Multi-Targeting.md) \ No newline at end of file +- [Getting Started](../Getting-Started.md) +- [Activities](../telemetry/Activities.md), [Logging](../telemetry/Logging.md), [Metrics](../telemetry/Metrics.md) +- [Multi-Targeting](../telemetry/Multi-Targeting.md) \ No newline at end of file diff --git a/docs/wiki/reference/FAQ.md b/docs/wiki/reference/FAQ.md index f8474018..7444aa34 100644 --- a/docs/wiki/reference/FAQ.md +++ b/docs/wiki/reference/FAQ.md @@ -43,7 +43,7 @@ Add the NuGet package to your `.csproj`: ``` -See [Getting Started](Getting-Started.md) for more details. +See [Getting Started](../Getting-Started.md) for more details. ### Why do I need `PrivateAssets` and `IncludeAssets`? @@ -77,7 +77,7 @@ Generated files appear in `obj/Debug|Release//generated/Purview.Telemetry.S - **Logging** — events, state changes, debugging with structured data, error details - **Metrics** — counting occurrences, distributions, gauges for dashboards -**Pro tip:** use [Multi-Targeting](Multi-Targeting.md) to combine all three. +**Pro tip:** use [Multi-Targeting](../telemetry/Multi-Targeting.md) to combine all three. ### Why does my Activity method return `Activity?` instead of `Activity`? @@ -108,7 +108,7 @@ Use baggage sparingly as it increases overhead; use tags for most properties. ### What's the difference between Generation v1 and v2? -See [Logging](Logging.md) for the full comparison. In brief: +See [Logging](../telemetry/Logging.md) for the full comparison. In brief: - **Generation v2** — state-based output resembling the built-in `[LoggerMessage]` generator; supports dynamic message templates, `[ExpandEnumerable]`, and `[LogProperties]`; requires `Microsoft.Extensions.Telemetry.Abstractions`. - **Generation v1** — `LoggerMessage.Define` high-performance mode; limited to 6 non-exception parameters plus one `Exception`; no `[ExpandEnumerable]`/`[LogProperties]`. @@ -207,7 +207,7 @@ interface IProductTelemetry // ^^^^^^^^^^^^ instrument name ``` -See [Instrument naming](Metrics.md#instrument-naming) for the full rules. +See [Instrument naming](../telemetry/Metrics.md#instrument-naming) for the full rules. ## Registering names @@ -231,13 +231,13 @@ Libraries need no configuration at all, however many of them there are and howev ### Why can't my `ServiceDefaults` project generate the names itself? -Because of the reference direction: an Aspire-style `ServiceDefaults` is referenced *by* the projects that own the telemetry, so their assemblies are not in its reference graph. Generate the class in each host (executable) project and pass the arrays into `ServiceDefaults`, as the [sample application](Sample-Application.md#registering-names-with-aspire) does. +Because of the reference direction: an Aspire-style `ServiceDefaults` is referenced *by* the projects that own the telemetry, so their assemblies are not in its reference graph. Generate the class in each host (executable) project and pass the arrays into `ServiceDefaults`, as the [sample application](../guide/Sample-Application.md#registering-names-with-aspire) does. ## Multi-targeting ### Can I generate Activities, Logging, AND Metrics from one interface? -Yes — this is called [Multi-Targeting](Multi-Targeting.md): +Yes — this is called [Multi-Targeting](../telemetry/Multi-Targeting.md): ```csharp [ActivitySource("MyApp")] @@ -361,7 +361,7 @@ Yes. Generated implementation and DI classes are internal by default, so they do ### How do I test code that uses telemetry interfaces? -Just mock the interface — see [Testing](Testing.md): +Just mock the interface — see [Testing](../guide/Testing.md): ```csharp var mockTelemetry = Substitute.For(); @@ -372,6 +372,6 @@ var service = new OrderService(mockTelemetry); - **Documentation** — [purview.dev](https://purview.dev/docs/telemetry-sourcegenerator/) - **Issues** — [GitHub Issues](https://github.com/purview-dev/telemetry-sourcegenerator/issues) -- **Sample** — [Sample Application](Sample-Application.md) +- **Sample** — [Sample Application](../guide/Sample-Application.md) When reporting a bug, include the package version, .NET version, a minimal repro, expected vs actual behaviour, and any `TSG` diagnostics. \ No newline at end of file diff --git a/docs/wiki/reference/Generated-Output.md b/docs/wiki/reference/Generated-Output.md index d52931fa..7c4489d9 100644 --- a/docs/wiki/reference/Generated-Output.md +++ b/docs/wiki/reference/Generated-Output.md @@ -1,6 +1,6 @@ # Generated Output -This page shows real generated output from the [sample application](Sample-Application.md), produced by `5.0.0`. The interface: +This page shows real generated output from the [sample application](../guide/Sample-Application.md), produced by `5.0.0`. The interface: ```csharp [ActivitySource] @@ -34,7 +34,7 @@ generates one partial `EntityStoreTelemetryCore` class split across Activity, Lo > Because the class is `partial` across those files, a member that belongs to the class rather than to one target is > declared by exactly one of them: the class attributes and the DI extension by the canonical target (Activities, else > Logging, else Metrics), the constructor by Logging or Metrics, and a union accessor (`UnwrapUnion_`) by the -> canonical target, once per union type. See [Shared members](Multi-Targeting.md#shared-members-and-which-file-declares-them). +> canonical target, once per union type. See [Shared members](../telemetry/Multi-Targeting.md#shared-members-and-which-file-declares-them). > [!NOTE] > To inspect the generated output in your own project, enable `EmitCompilerGeneratedFiles` and look under `obj///generated/Purview.Telemetry.SourceGenerator/Purview.Telemetry.SourceGenerator.TelemetrySourceGenerator/`. @@ -171,7 +171,7 @@ internal sealed partial class EntityStoreTelemetryCore : IEntityStoreTelemetry } ``` -These methods fit the v1 limits (≤ 6 non-exception parameters, no `[ExpandEnumerable]`/`[LogProperties]`), so `LoggerGenerationMode.Auto` selected v1 `LoggerMessage.Define` generation with an `IsEnabled` fast-path. See [Logging](Logging.md). +These methods fit the v1 limits (≤ 6 non-exception parameters, no `[ExpandEnumerable]`/`[LogProperties]`), so `LoggerGenerationMode.Auto` selected v1 `LoggerMessage.Define` generation with an `IsEnabled` fast-path. See [Logging](../telemetry/Logging.md). ## Metrics (`EntityStoreTelemetryCore.Metric.g.cs`) @@ -267,10 +267,10 @@ namespace SampleApp.APIService } ``` -`SampleApp.Shared` comes from the referenced project of the same name, whose `IWeatherForecastTelemetry` declares an ActivitySource and a Meter: the class aggregates this assembly's names with the names recorded by everything it references. See [Generation](Generation.md#telemetry-names). +`SampleApp.Shared` comes from the referenced project of the same name, whose `IWeatherForecastTelemetry` declares an ActivitySource and a Meter: the class aggregates this assembly's names with the names recorded by everything it references. See [Generation](../Generation.md#telemetry-names). ## See also -- [Sample Application](Sample-Application.md) — the full source -- [Generation](Generation.md) — controlling class names, DI, and names +- [Sample Application](../guide/Sample-Application.md) — the full source +- [Generation](../Generation.md) — controlling class names, DI, and names - [Diagnostics](Diagnostics.md) — analyzer rules \ No newline at end of file diff --git a/docs/wiki/reference/Performance.md b/docs/wiki/reference/Performance.md index 25897613..bc2f83ac 100644 --- a/docs/wiki/reference/Performance.md +++ b/docs/wiki/reference/Performance.md @@ -22,7 +22,7 @@ is **gitignored**: the artifacts stay on the machine that produced them, and the ## Environment -``` +```text BenchmarkDotNet v0.16.0-preview.2, Windows 11 (10.0.26300.9974) 13th Gen Intel Core i9-13900KF 3.00GHz, 1 CPU, 32 logical and 24 physical cores Memory: 63.77 GB Total, 39.06 GB Available diff --git a/docs/wiki/reference/Platform-Support.md b/docs/wiki/reference/Platform-Support.md index 3cc277bc..cc5baf76 100644 --- a/docs/wiki/reference/Platform-Support.md +++ b/docs/wiki/reference/Platform-Support.md @@ -2,7 +2,7 @@ This page lists the target frameworks and hosting models the generator supports, what the generated code guarantees under trimming and Native AOT, and what .NET 11 changes. Every "Verified by" entry names a gate that runs on each -release; see [Release Flow](Release-Flow.md#release-gates). +release; see [Release Flow](../contributing/Release-Flow.md#release-gates). ## What's new for .NET 11 @@ -23,11 +23,11 @@ release; see [Release Flow](Release-Flow.md#release-gates). `typeof` list, the union's partial static factory signatures, a nested `IUnionMembers` provider, then its constructors — and emits the non-boxing accessor against the members the unions generator will produce. No reference to Purview.Unions is taken: both attributes are matched by metadata name and namespace. See - [Tags, Baggage, and Parameter Attributes](Tags-and-Baggage.md#purviewunions-generateunion-types). + [Tags, Baggage, and Parameter Attributes](../telemetry/Tags-and-Baggage.md#purviewunions-generateunion-types). - **`ActivitySourceFactory` (opt-in).** `[assembly: ActivitySourceGeneration(..., UseActivitySourceFactory = true)]` makes the generated classes create their sources from the injected .NET 11 factory, registered with `services.AddTracing()`, instead of a static `ActivitySource`. See - [Activities](Activities.md#creating-sources-with-activitysourcefactory-net-11). `TSG3023` reports the option when + [Activities](../telemetry/Activities.md#creating-sources-with-activitysourcefactory-net-11). `TSG3023` reports the option when the factory type is unavailable. - **Static interface members are ignored.** C# 15 lets an interface declare non-virtual static members on any runtime. The generator and analyzer now skip static members everywhere, so a static helper on a telemetry interface is no @@ -59,7 +59,7 @@ test legs and benchmarks no longer build them. The generated code still works on The `UseActivitySourceFactory` entries for `net48` and `net10.0` follow from the package's assets and are not run by a gate; only `net11.0` is. ¹ Logging v2 needs `Microsoft.Extensions.Telemetry.Abstractions` (`LoggerMessageHelper`); without it the generator -uses v1. See [Logging Generation v2](Logging-Generation-v2.md). +uses v1. See [Logging Generation v2](../telemetry/Logging-Generation-v2.md). **Build toolchain:** Visual Studio 2026 (18.x) or the .NET 10 SDK or later (Roslyn 5.9.0+). The generator itself is a `netstandard2.0` Roslyn component. This repository builds and validates with the .NET 11 SDK pinned in `global.json`. @@ -114,7 +114,7 @@ source and a static one. | `[LoggerMessage(SkipEnabledCheck = true)]` | Implemented: `[Log(SkipEnabledCheck = true)]` and the same property on every level attribute omit the generated `IsEnabled` guard | any TFM | [`LoggerMessageAttribute.SkipEnabledCheck`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loggermessageattribute.skipenabledcheck) | | Generic logging methods (the .NET 11 `[LoggerMessage]` generator supports them) | Implemented: a generic method is generated with the interface method's type parameters and constraints mirrored, and always uses the state-based (v2) path because `LoggerMessage.Define` cannot carry an open generic. `TSG1005` now reports only the shapes that cannot be generated. | any TFM | [What's new in .NET 11 libraries](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries#generic-source-generated-logging-methods) | | Generic interfaces | Implemented: the generated class becomes generic over the interface's type parameters with the same constraints, and the DI extension registers the open generic (`typeof(IFoo<>)`). `TSG1004` now reports only a nested generic interface or the `allows ref struct` anti-constraint. | any TFM | [Generic type parameters](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/generics/generic-type-parameters) | -| `[LogProperties(Transitive = true)]` nested property expansion | Implemented: nested properties are flattened to dotted tag names, with `[LogPropertyIgnore]` and `SkipNullProperties` honoured at every level and a 32-type/deepest-chain bound (`Microsoft.Gen.Logging`'s `MaxTypeTraversalDepth`). See [Logging](Logging.md#logpropertiestransitive--true). | any TFM | [`LogPropertiesAttribute.Transitive`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.logpropertiesattribute.transitive) | +| `[LogProperties(Transitive = true)]` nested property expansion | Implemented: nested properties are flattened to dotted tag names, with `[LogPropertyIgnore]` and `SkipNullProperties` honoured at every level and a 32-type/deepest-chain bound (`Microsoft.Gen.Logging`'s `MaxTypeTraversalDepth`). See [Logging](../telemetry/Logging.md#logpropertiestransitive--true). | any TFM | [`LogPropertiesAttribute.Transitive`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.logpropertiesattribute.transitive) | | `[TagProvider]` contributed log tags | Implemented for the pair-returning provider shape (`IEnumerable>` and the `string` variant), in both the state-based and `LoggerMessage.Define` paths (the latter upgrades the method to the state-based path). Diagnostics `TSG2024`-`TSG2027`. | any TFM | [`TagProviderAttribute`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.tagproviderattribute) | | `Activity.AddException` | Implemented: when the compilation resolves it, the generated exception helper calls `activity.AddException(exception, tags, timestamp)` and passes `exception.escaped` through the `TagList`; the framework writes `exception.type`/`message`/`stacktrace` itself. Otherwise the hand-built `ActivityEvent` is kept, so `net48` and older DiagnosticSource keep working. | `net48`+ with System.Diagnostics.DiagnosticSource 9+; gated on the symbol, never the TFM | [`Activity.AddException`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activity.addexception) | | `MeterOptions.Version` and `MeterOptions.Tags` | Implemented: `[Meter(Version = "1.2.3", Tags = "env=prod,region=eu")]` or the assembly-level `[MeterGeneration(...)]`, comma-separated `key=value`, interface-level overriding assembly-level per key. Without an `IMeterFactory` the version goes to the `Meter(name, version)` constructor, which is the only metadata that overload carries. `MeterOptions.Scope` stays unreachable: it takes an object instance an attribute cannot express. | Any TFM that resolves `IMeterFactory` (Microsoft.Extensions.Diagnostics.Abstractions), including `net48`; without it the version goes to the `Meter(name, version)` constructor and the tags do not apply | [`MeterOptions`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics.meteroptions) | @@ -163,7 +163,7 @@ Everything else in .NET 11 was evaluated and deliberately left alone: | `[TagProvider]`'s `ITagCollector` provider shape (`static void M(ITagCollector collector, T value)`) | Reported as `TSG2026`. Implementing `Microsoft.Extensions.Logging.ITagCollector` in generated code would pull `Microsoft.Extensions.Compliance.Classification` into every consumer's build for one optional feature, so only the pair-returning shape (`IEnumerable>` / `IEnumerable>`) is accepted. | [`ITagCollector`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.itagcollector) | | An end-to-end test with Purview.Unions and this generator in one compilation | The `[GenerateUnion]` support is proven by integration tests that declare a stand-in `Purview.Unions.GenerateUnionAttribute` in the test source, because Purview.Unions is not on a CI-safe package feed yet and the shipped generator must not take a reference to it. Running both generators together is a follow-up once the package is published. The generator reads the case list from source, so nothing in the product depends on the other generator's output. | [Purview.Unions](https://github.com/purview-dev/unions) | | A `Nullable` (nullable struct) property expanded transitively by `[LogProperties(Transitive = true)]` | Logged as a single tag. Reaching its members needs a `.Value` hop that the generated null-conditional chain cannot express, so the branch stops there rather than emitting code that does not compile. | [`LogPropertiesAttribute.Transitive`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.logpropertiesattribute.transitive) | -| A metric method whose measurement is one of the method's own type parameters | Reported as `TSG1005`, and the method is emitted as a throwing stub. An instrument is a **field**, and a field cannot name a method's type parameter. Declaring that type parameter on the interface makes the generated class generic and the instrument field can then use it; that shape is supported. | [Metrics](Metrics.md) | +| A metric method whose measurement is one of the method's own type parameters | Reported as `TSG1005`, and the method is emitted as a throwing stub. An instrument is a **field**, and a field cannot name a method's type parameter. Declaring that type parameter on the interface makes the generated class generic and the instrument field can then use it; that shape is supported. | [Metrics](../telemetry/Metrics.md) | | A nested generic interface | Reported as `TSG1004`. The enclosing type's type arguments cannot be repeated on the generated class. | [Generic types](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/generics/) | | `ActivitySourceOptions` version, tags and `TelemetrySchemaUrl` for factory-created sources | Version and `TelemetrySchemaUrl` are implemented; `Tags` are not, because an attribute cannot express the `IEnumerable>` the option takes. Set them in `AddTracing` configuration instead. | [What's new in .NET 11 libraries](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries) | | `AddTracing` rules from generated registrations | Rules are application configuration, so the generated `Add{Interface}()` does not call `AddTracing()`. Call it yourself. | [Distributed tracing configuration](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/distributed-tracing-configuration) | diff --git a/docs/wiki/reference/Refactorings.md b/docs/wiki/reference/Refactorings.md index faaa6178..425cc8e8 100644 --- a/docs/wiki/reference/Refactorings.md +++ b/docs/wiki/reference/Refactorings.md @@ -109,5 +109,5 @@ If a refactoring does not cover a call pattern, convert it manually: ## See also -- [Migration from ILogger](Migration-From-ILogger.md) -- [Migration from Activities](Migration-From-Activities.md) \ No newline at end of file +- [Migration from ILogger](../migration/Migration-From-ILogger.md) +- [Migration from Activities](../migration/Migration-From-Activities.md) \ No newline at end of file diff --git a/docs/wiki/telemetry/Activities.md b/docs/wiki/telemetry/Activities.md index 5c4a7769..4bd2e9d8 100644 --- a/docs/wiki/telemetry/Activities.md +++ b/docs/wiki/telemetry/Activities.md @@ -3,7 +3,7 @@ All activity-related attributes live in the `Purview.Telemetry` namespace. To signal an interface for Activity generation, decorate it with the `[ActivitySource]` attribute. > [!IMPORTANT] -> All attributes are now in the unified `Purview.Telemetry` namespace. See [Breaking Changes](Breaking-Changes.md#namespace-consolidation) for migration details. +> All attributes are now in the unified `Purview.Telemetry` namespace. See [Breaking Changes](../reference/Breaking-Changes.md#namespace-consolidation) for migration details. ## ActivitySource naming @@ -40,7 +40,7 @@ builder.Services.AddOrderTelemetry(); - The option is opt-in because it changes the generated constructor. Without it, nothing changes when you move to .NET 11. - It is gated on the compilation, not the target framework: it needs `System.Diagnostics.ActivitySourceFactory`, which ships in .NET 11 and in the System.Diagnostics.DiagnosticSource 11 package (net462, netstandard2.0, net10.0). When the - type cannot be resolved the generator keeps the static source and reports [`TSG3023`](Diagnostics.md#tsg3023). + type cannot be resolved the generator keeps the static source and reports [`TSG3023`](../reference/Diagnostics.md#tsg3023). - Listeners registered with `ActivitySource.AddActivityListener` (OpenTelemetry's included) still see factory-created sources. ### Version and telemetry schema URL @@ -65,7 +65,7 @@ _activitySource = activitySourceFactory.Create(new global::System.Diagnostics.Ac ``` Both are only consumed by the factory: a static `ActivitySource` has nowhere to put them, so setting either without -`UseActivitySourceFactory` reports [`TSG3024`](Diagnostics.md#tsg3024). `ActivitySourceOptions.Tags` is not expressible +`UseActivitySourceFactory` reports [`TSG3024`](../reference/Diagnostics.md#tsg3024). `ActivitySourceOptions.Tags` is not expressible from an attribute; add tags through `AddTracing` configuration instead. ## Activity, Event, or Context @@ -229,4 +229,4 @@ Defines a method that creates an `ActivityEvent`. - [Tags, Baggage, and Parameter Attributes](Tags-and-Baggage.md) — parameter-level attributes - [Multi-Targeting](Multi-Targeting.md) — combining Activities with Logging and Metrics -- [Diagnostics](Diagnostics.md) — the `TSG3xxx` Activity rules \ No newline at end of file +- [Diagnostics](../reference/Diagnostics.md) — the `TSG3xxx` Activity rules \ No newline at end of file diff --git a/docs/wiki/telemetry/Logging-Generation-v1.md b/docs/wiki/telemetry/Logging-Generation-v1.md index 34fb7635..73ce6b99 100644 --- a/docs/wiki/telemetry/Logging-Generation-v1.md +++ b/docs/wiki/telemetry/Logging-Generation-v1.md @@ -1,7 +1,9 @@ # Logging Generation v1 +The original `LoggerMessage.Define`-based logging mode: what it emits, its limits, and when it is still generated. + > [!IMPORTANT] -> All attributes are now in the unified `Purview.Telemetry` namespace. See [Breaking Changes](Breaking-Changes.md#namespace-consolidation) for migration details. +> All attributes are now in the unified `Purview.Telemetry` namespace. See [Breaking Changes](../reference/Breaking-Changes.md#namespace-consolidation) for migration details. Generation v1 is the previous style of log generation, built on [`LoggerMessage.Define`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loggermessagedefine) from the [high-performance logging](https://learn.microsoft.com/en-us/dotnet/core/extensions/high-performance-logging) libraries. @@ -83,4 +85,4 @@ If no level is specified on the method and an `Exception` parameter is present, - [Logging Generation v2](Logging-Generation-v2.md) — the state-based mode with `[ExpandEnumerable]`/`[LogProperties]` - [Logging](Logging.md) — choosing between v1 and v2 -- [Diagnostics](Diagnostics.md) — the `TSG2xxx` Logging rules \ No newline at end of file +- [Diagnostics](../reference/Diagnostics.md) — the `TSG2xxx` Logging rules \ No newline at end of file diff --git a/docs/wiki/telemetry/Logging-Generation-v2.md b/docs/wiki/telemetry/Logging-Generation-v2.md index b192cfc1..f9d83b53 100644 --- a/docs/wiki/telemetry/Logging-Generation-v2.md +++ b/docs/wiki/telemetry/Logging-Generation-v2.md @@ -1,7 +1,9 @@ # Logging Generation v2 +The state-based logging mode: its generated shape, `MessageTemplate`s, array expansion, and when it is chosen over v1. + > [!IMPORTANT] -> All attributes are now in the unified `Purview.Telemetry` namespace. See [Breaking Changes](Breaking-Changes.md#namespace-consolidation) for migration details. +> All attributes are now in the unified `Purview.Telemetry` namespace. See [Breaking Changes](../reference/Breaking-Changes.md#namespace-consolidation) for migration details. Generation v2 is the state-based logging mode. Its output closely resembles the built-in [`[LoggerMessage]`](https://learn.microsoft.com/en-us/dotnet/core/extensions/logger-message-generator) generator, but with additional features: dynamically generated `MessageTemplate`s and the ability to expand array/`IEnumerable` parameters. @@ -86,4 +88,4 @@ The companion `LogPropertyIgnoreAttribute` marks a property to be skipped during - [Logging Generation v1](Logging-Generation-v1.md) — the `LoggerMessage.Define` mode and its limits - [Logging](Logging.md) — choosing between v1 and v2 -- [Diagnostics](Diagnostics.md) — the `TSG2xxx` Logging rules \ No newline at end of file +- [Diagnostics](../reference/Diagnostics.md) — the `TSG2xxx` Logging rules \ No newline at end of file diff --git a/docs/wiki/telemetry/Logging.md b/docs/wiki/telemetry/Logging.md index 91f936a3..74fd1b2b 100644 --- a/docs/wiki/telemetry/Logging.md +++ b/docs/wiki/telemetry/Logging.md @@ -157,7 +157,7 @@ interface IWeatherTelemetry - **Divergence:** the .NET 11 collector shape (`static void GetTags(ITagCollector collector, T value)`) is not accepted and is reported as `TSG2026`. Implementing `ITagCollector` in generated code would pull `Microsoft.Extensions.Compliance.Classification` into every consumer's build, so only the pair-returning shape is - supported. This is recorded in [Platform Support](Platform-Support.md). + supported. This is recorded in [Platform Support](../reference/Platform-Support.md). - A `[TagProvider]` parameter makes the method state-based (v2), even if `V1` was requested, because `LoggerMessage.Define` has no tag bag to add the pairs to. @@ -165,5 +165,5 @@ interface IWeatherTelemetry - [Generation v1](Logging-Generation-v1.md) — `LoggerMessage.Define` mode and its limits - [Generation v2](Logging-Generation-v2.md) — state-based mode, `[ExpandEnumerable]`, `[LogProperties]` -- [Breaking Changes](Breaking-Changes.md#namespace-consolidation) — namespace migration -- [Diagnostics](Diagnostics.md) — the `TSG2xxx` Logging rules \ No newline at end of file +- [Breaking Changes](../reference/Breaking-Changes.md#namespace-consolidation) — namespace migration +- [Diagnostics](../reference/Diagnostics.md) — the `TSG2xxx` Logging rules \ No newline at end of file diff --git a/docs/wiki/telemetry/Metrics.md b/docs/wiki/telemetry/Metrics.md index 11ab8585..356fb8be 100644 --- a/docs/wiki/telemetry/Metrics.md +++ b/docs/wiki/telemetry/Metrics.md @@ -1,7 +1,9 @@ # Metrics +The metric attributes and instruments: counters, histograms, gauges, observable instruments, and meter naming. + > [!IMPORTANT] -> All attributes are now in the unified `Purview.Telemetry` namespace. See [Breaking Changes](Breaking-Changes.md#namespace-consolidation) for migration details. +> All attributes are now in the unified `Purview.Telemetry` namespace. See [Breaking Changes](../reference/Breaking-Changes.md#namespace-consolidation) for migration details. All metric-related attributes live in the `Purview.Telemetry` namespace. To signal an interface for meter generation, decorate it with the `[Meter]` attribute. @@ -235,4 +237,4 @@ instead of silently doing nothing. - [Tags, Baggage, and Parameter Attributes](Tags-and-Baggage.md) — tagging parameters - [Multi-Targeting](Multi-Targeting.md) — combining Metrics with Activities and Logging -- [Diagnostics](Diagnostics.md) — the `TSG4xxx` Metrics rules \ No newline at end of file +- [Diagnostics](../reference/Diagnostics.md) — the `TSG4xxx` Metrics rules \ No newline at end of file diff --git a/docs/wiki/telemetry/Multi-Targeting.md b/docs/wiki/telemetry/Multi-Targeting.md index 0e48b1ad..9500aac5 100644 --- a/docs/wiki/telemetry/Multi-Targeting.md +++ b/docs/wiki/telemetry/Multi-Targeting.md @@ -288,4 +288,4 @@ also registers `[ActivitySource]`, and a target never sees the parameters of a m - [Logging](Logging.md) — logging generation details - [Metrics](Metrics.md) — metrics generation details - [Tags, Baggage, and Parameter Attributes](Tags-and-Baggage.md) — `[Tag]`, `[Baggage]`, `[ExcludeTargets]` -- [Diagnostics](Diagnostics.md) — error codes and resolutions \ No newline at end of file +- [Diagnostics](../reference/Diagnostics.md) — error codes and resolutions \ No newline at end of file diff --git a/docs/wiki/telemetry/Tags-and-Baggage.md b/docs/wiki/telemetry/Tags-and-Baggage.md index 7cf7f671..f6ece3f6 100644 --- a/docs/wiki/telemetry/Tags-and-Baggage.md +++ b/docs/wiki/telemetry/Tags-and-Baggage.md @@ -1,5 +1,7 @@ # Tags, Baggage, and Parameter Attributes +The parameter-level attributes that control whether a value becomes a tag, a baggage entry, or a log property. + > [!IMPORTANT] > All attributes are now in the unified `Purview.Telemetry` namespace. @@ -54,7 +56,7 @@ interface IOrderTelemetry [Tag]string userName // Generated: "username" ``` -To change the convention, see [Naming conventions](Generation.md#naming-conventions). +To change the convention, see [Naming conventions](../Generation.md#naming-conventions). ### Best practices @@ -266,5 +268,5 @@ as the consumer's compiler can see the type. Declaring one with the `union` keyw - [Activities](Activities.md) — tags/baggage in Activity generation - [Metrics](Metrics.md) — tags in metrics generation - [Multi-Targeting](Multi-Targeting.md) — excluding parameters per target -- [Generation](Generation.md) — naming conventions -- [Breaking Changes](Breaking-Changes.md#opentelemetry-aligned-naming) — v3 to v4 naming changes \ No newline at end of file +- [Generation](../Generation.md) — naming conventions +- [Breaking Changes](../reference/Breaking-Changes.md#opentelemetry-aligned-naming) — v3 to v4 naming changes \ No newline at end of file diff --git a/samples/SampleApp/README.md b/samples/SampleApp/README.md index c91748d4..2ce0d428 100644 --- a/samples/SampleApp/README.md +++ b/samples/SampleApp/README.md @@ -287,7 +287,7 @@ dotnet-counters monitor --process-id --counters SampleApp.APIService.Servi ## Learn More - [Purview Telemetry Source Generator docs](../../README.md) -- [Sample Application](https://purview.dev/docs/telemetry-sourcegenerator/sample-application/) — annotated walkthroughs, sequence diagrams, dashboard screenshots -- [Generated Output](https://purview.dev/docs/telemetry-sourcegenerator/generated-output/) — full annotated examples of generated code +- [Sample Application](https://purview.dev/docs/telemetry-sourcegenerator/guide/sample-application/) — annotated walkthroughs, sequence diagrams, dashboard screenshots +- [Generated Output](https://purview.dev/docs/telemetry-sourcegenerator/reference/generated-output/) — full annotated examples of generated code - [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/) diff --git a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md index ac52d515..d4ae8ed7 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md @@ -5,14 +5,14 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- -TSG3023 | Activity.Usage | Warning | [ActivitySourceFactory is not available](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg3023) -TSG3024 | Activity.Usage | Warning | [ActivitySourceOptions require UseActivitySourceFactory](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg3024) -TSG4010 | Metrics.Usage | Warning | [Gauge is not available](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg4010) -TSG4011 | Metrics.Usage | Warning | [HistogramBucketBoundaries is not available](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg4011) -TSG4012 | Metrics.Usage | Warning | [HistogramBucketBoundaries is not a list of numbers](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg4012) -TSG2022 | Logging.Usage | Error | [params parameters are not supported](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg2022) -TSG2023 | Logging.Usage | Error | [ref, out and ref struct parameters are not supported](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg2023) -TSG2024 | Logging.Usage | Error | [TagProvider type could not be resolved](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg2024) -TSG2025 | Logging.Usage | Error | [TagProvider method could not be found](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg2025) -TSG2026 | Logging.Usage | Error | [TagProvider method has an invalid signature](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg2026) -TSG2027 | Logging.Usage | Warning | [TagProvider is only supported on logging parameters](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg2027) +TSG3023 | Activity.Usage | Warning | [ActivitySourceFactory is not available](https://purview.dev/docs/telemetry-sourcegenerator/reference/diagnostics/#tsg3023) +TSG3024 | Activity.Usage | Warning | [ActivitySourceOptions require UseActivitySourceFactory](https://purview.dev/docs/telemetry-sourcegenerator/reference/diagnostics/#tsg3024) +TSG4010 | Metrics.Usage | Warning | [Gauge is not available](https://purview.dev/docs/telemetry-sourcegenerator/reference/diagnostics/#tsg4010) +TSG4011 | Metrics.Usage | Warning | [HistogramBucketBoundaries is not available](https://purview.dev/docs/telemetry-sourcegenerator/reference/diagnostics/#tsg4011) +TSG4012 | Metrics.Usage | Warning | [HistogramBucketBoundaries is not a list of numbers](https://purview.dev/docs/telemetry-sourcegenerator/reference/diagnostics/#tsg4012) +TSG2022 | Logging.Usage | Error | [params parameters are not supported](https://purview.dev/docs/telemetry-sourcegenerator/reference/diagnostics/#tsg2022) +TSG2023 | Logging.Usage | Error | [ref, out and ref struct parameters are not supported](https://purview.dev/docs/telemetry-sourcegenerator/reference/diagnostics/#tsg2023) +TSG2024 | Logging.Usage | Error | [TagProvider type could not be resolved](https://purview.dev/docs/telemetry-sourcegenerator/reference/diagnostics/#tsg2024) +TSG2025 | Logging.Usage | Error | [TagProvider method could not be found](https://purview.dev/docs/telemetry-sourcegenerator/reference/diagnostics/#tsg2025) +TSG2026 | Logging.Usage | Error | [TagProvider method has an invalid signature](https://purview.dev/docs/telemetry-sourcegenerator/reference/diagnostics/#tsg2026) +TSG2027 | Logging.Usage | Warning | [TagProvider is only supported on logging parameters](https://purview.dev/docs/telemetry-sourcegenerator/reference/diagnostics/#tsg2027) diff --git a/src/src/SourceGenerator/Helpers/LogPropertiesFlattener.cs b/src/src/SourceGenerator/Helpers/LogPropertiesFlattener.cs index 6b113c7d..a8f72df5 100644 --- a/src/src/SourceGenerator/Helpers/LogPropertiesFlattener.cs +++ b/src/src/SourceGenerator/Helpers/LogPropertiesFlattener.cs @@ -19,7 +19,7 @@ namespace Purview.Telemetry.SourceGenerator.Helpers; /// MaxTypeTraversalDepth. /// /// -/// Two deliberate divergences, both documented on docs/wiki/Logging.md: a type already on the visited chain +/// Two deliberate divergences, both documented on docs/wiki/telemetry/Logging.md: a type already on the visited chain /// stops only that branch instead of abandoning the whole parameter, and a Nullable<T> property is /// flattened no further because the extra .Value hop the accessor would need does not fit the generated /// null-conditional chain. @@ -204,6 +204,8 @@ static bool IsTransitivelyExpandable(ITypeSymbol type) if (named.AllInterfaces.Any(static i => i.SpecialType == SpecialType.System_Collections_IEnumerable)) return false; + // A delegate is one tag: expanding Action into Method/Target is not what a reader wants, and the .NET + // generator does not expand delegates either. return true; } } diff --git a/src/src/SourceGenerator/Helpers/TagProviderResolver.cs b/src/src/SourceGenerator/Helpers/TagProviderResolver.cs index 1fc849df..09c43fcd 100644 --- a/src/src/SourceGenerator/Helpers/TagProviderResolver.cs +++ b/src/src/SourceGenerator/Helpers/TagProviderResolver.cs @@ -38,7 +38,7 @@ readonly record struct TagProviderResolution(TagProviderStatus Status, TagProvid /// ITagCollector in generated code would pull Microsoft.Extensions.Compliance.Classification into every /// consumer's build. Only the pair-returning shape the generated state can consume is supported; a collector-shaped /// method is reported as and recorded in -/// docs/wiki/Platform-Support.md. +/// docs/wiki/reference/Platform-Support.md. /// static class TagProviderResolver { diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.MeterNaming.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.MeterNaming.cs index 32237bea..bc099eeb 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.MeterNaming.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.MeterNaming.cs @@ -371,7 +371,7 @@ await Assert } /// - /// The interface every naming example in README.md, docs/wiki/Metrics.md and docs/wiki/FAQ.md uses, so the + /// The interface every naming example in README.md, docs/wiki/telemetry/Metrics.md and docs/wiki/reference/FAQ.md uses, so the /// documented names are pinned by the generator itself. /// const string DocumentedProductTelemetry = """ From a5e053a692f13cb1804a7ecc0ec241d4f1f982e6 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sun, 15 Nov 2026 00:34:15 +0000 Subject: [PATCH 06/12] fix(unions): write the nullable union local through Assignment The nullable-union accessor declared its local as raw text through two Write calls. The framework's PSGFR19 rejects that shape ('Write' with a statement should use 'Assignment'), so the generator did not build against Purview.SourceGeneratorFramework 2.1.0. The typed Assignment overload emits the same line. Verified against the pinned 2.0.0 as well, since the SDK bump is not part of this change. The caching suite also gains the three cases it was missing: deleting an interface removes its generated source, renaming replaces the hint name, and a changed global option re-resolves the generation context instead of leaving it cached. --- .../Emitters/UnionAccessorEmitter.cs | 7 +- .../TelemetrySourceGeneratorCachingTests.cs | 131 +++++++++++++++++- 2 files changed, 134 insertions(+), 4 deletions(-) diff --git a/src/src/SourceGenerator/Emitters/UnionAccessorEmitter.cs b/src/src/SourceGenerator/Emitters/UnionAccessorEmitter.cs index 086fd9e0..d345f80d 100644 --- a/src/src/SourceGenerator/Emitters/UnionAccessorEmitter.cs +++ b/src/src/SourceGenerator/Emitters/UnionAccessorEmitter.cs @@ -61,8 +61,11 @@ static void EmitAccessor(CodeWriter writer, UnionTargetRecord union) writer .NewLine() - .Write(union.UnderlyingUnionType.RenderFullNameForNullable(useNullable)) - .Write(" unionValue = value.GetValueOrDefault();") + .Assignment( + union.UnderlyingUnionType.RenderFullNameForNullable(useNullable), + "unionValue", + "value.GetValueOrDefault()" + ) .NewLine(); value = "unionValue"; diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorCachingTests.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorCachingTests.cs index 0894099d..27ffd93f 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorCachingTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorCachingTests.cs @@ -128,8 +128,9 @@ public interface IMultiUnionTelemetry /// ForAttributeWithMetadataName tracking names (TelemetrySourceGenerator_*); the /// remaining stages come from the framework's GenerationContextValueProvider /// (GetGenerationContext_*). The generator passes a disable-property - /// name, so no GetMSBuildPropertyValue_* stages exist and a property-only invalidation test - /// is not applicable. + /// name, so no GetMSBuildPropertyValue_* stages exist; a global option is still observed + /// through the generation context, which + /// proves. /// static bool IsFrameworkStage(string trackingName) => trackingName.StartsWith("TelemetrySourceGenerator_", StringComparison.Ordinal) @@ -173,8 +174,10 @@ static ImmutableDictionary GetGeneratedSources(GeneratorRunResul [Test] public async Task Generate_UnchangedCompilation_OutputCached(CancellationToken cancellationToken) { + // Act var result = await GenerateIncrementalAsync([ActivityInterface, LoggerInterface], Options, cancellationToken); + // Assert await Assert .That(GetFrameworkReasons(result.Runs[0]).All(static reason => reason == IncrementalStepRunReason.New)) .IsTrue() @@ -193,11 +196,13 @@ reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged [Test] public async Task Generate_UnchangedCompilation_OutputIsStable(CancellationToken cancellationToken) { + // Act var result = await GenerateIncrementalAsync([ActivityInterface, LoggerInterface], Options, cancellationToken); var first = GetGeneratedSources(result.Runs[0].RunResult); var second = GetGeneratedSources(result.Runs[1].RunResult); + // Assert await Assert.That(second.Count).IsEqualTo(first.Count); foreach (var source in first) await Assert.That(second[source.Key]).IsEqualTo(source.Value); @@ -206,14 +211,17 @@ public async Task Generate_UnchangedCompilation_OutputIsStable(CancellationToken [Test] public async Task Generate_UnrelatedChange_OutputStaysCached(CancellationToken cancellationToken) { + // Arrange var inputs = new[] { new IncrementalRunInput([ActivityInterface, LoggerInterface], []), new IncrementalRunInput([ActivityInterface, LoggerInterface, UnrelatedType], []), }; + // Act var result = await GenerateIncrementalAsync(inputs, Options, cancellationToken); var reasons = GetFrameworkReasons(result.Runs[1]); + // Assert await Assert.That(reasons).IsNotEmpty(); await Assert .That( @@ -228,16 +236,19 @@ reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged [Test] public async Task Generate_UnrelatedChange_AllOutputUnchanged(CancellationToken cancellationToken) { + // Arrange var inputs = new[] { new IncrementalRunInput([ActivityInterface, LoggerInterface], []), new IncrementalRunInput([ActivityInterface, LoggerInterface, UnrelatedType], []), }; + // Act var result = await GenerateIncrementalAsync(inputs, Options, cancellationToken); var first = GetGeneratedSources(result.Runs[0].RunResult); var second = GetGeneratedSources(result.Runs[1].RunResult); + // Assert await Assert.That(second.Count).IsEqualTo(first.Count); foreach (var source in first) await Assert.That(second[source.Key]).IsEqualTo(source.Value); @@ -246,11 +257,14 @@ public async Task Generate_UnrelatedChange_AllOutputUnchanged(CancellationToken [Test] public async Task Generate_ActivityEdit_OutputReRuns(CancellationToken cancellationToken) { + // Arrange var edited = ActivityInterface.ReplaceOrdinal("Activity([Tag]", "Activity2([Tag]"); var inputs = new[] { new IncrementalRunInput([ActivityInterface], []), new IncrementalRunInput([edited], []) }; + // Act var result = await GenerateIncrementalAsync(inputs, Options, cancellationToken); var reasons = GetStageReasons(result.Runs[1], "TelemetrySourceGenerator_Activities"); + // Assert await Assert .That(reasons.Any(static reason => reason == IncrementalStepRunReason.Modified)) .IsTrue() @@ -260,15 +274,18 @@ await Assert [Test] public async Task Generate_LoggerEdit_OnlyLoggerOutputChanges(CancellationToken cancellationToken) { + // Arrange var editedLogger = LoggerInterface.ReplaceOrdinal("void Log(", "void Log2("); var inputs = new[] { new IncrementalRunInput([ActivityInterface, LoggerInterface], []), new IncrementalRunInput([ActivityInterface, editedLogger], []), }; + // Act var result = await GenerateIncrementalAsync(inputs, Options, cancellationToken); var loggerReasons = GetStageReasons(result.Runs[1], "TelemetrySourceGenerator_Logging"); + // Assert await Assert .That(loggerReasons.Any(static reason => reason == IncrementalStepRunReason.Modified)) .IsTrue() @@ -310,6 +327,111 @@ await Assert } } + /// + /// Deleting an interface must delete its generated source. A pipeline that keys its outputs off a + /// Collect()ed aggregate can keep emitting the removed target, and nothing else in the suite + /// would notice: the remaining output is still correct. + /// + [Test] + public async Task Generate_InterfaceDeleted_ItsOutputIsRemoved(CancellationToken cancellationToken) + { + // Arrange + var inputs = new[] + { + new IncrementalRunInput([ActivityInterface, LoggerInterface], []), + new IncrementalRunInput([LoggerInterface], []), + }; + // Act + var result = await GenerateIncrementalAsync(inputs, Options, cancellationToken); + + var first = GetGeneratedSources(result.Runs[0].RunResult); + var second = GetGeneratedSources(result.Runs[1].RunResult); + + // Assert + await Assert + .That(first.Keys.Any(static key => key.EndsWith(".Activity.g.cs", StringComparison.Ordinal))) + .IsTrue() + .Because("the first run generates the activity target"); + await Assert + .That(second.Keys.Any(static key => key.EndsWith(".Activity.g.cs", StringComparison.Ordinal))) + .IsFalse() + .Because("deleting the interface must remove its generated source rather than leave it behind"); + await Assert + .That(second.Keys.Any(static key => key.EndsWith(".Logging.g.cs", StringComparison.Ordinal))) + .IsTrue() + .Because("the interface that was not deleted must still generate"); + } + + /// + /// A rename is the delete and the add in one edit. The hint name is derived from the interface, so the + /// old one must go and a new one must appear — a stale hint name is a duplicate-type error the + /// consumer sees, not a caching detail. Asserted on the sets rather than on the naming convention, so + /// the test does not repeat the class-name rules the emitters own. + /// + [Test] + public async Task Generate_InterfaceRenamed_ReplacesItsOutput(CancellationToken cancellationToken) + { + // Arrange + var renamed = ActivityInterface.ReplaceOrdinal("ITestActivities", "IRenamedActivities"); + var inputs = new[] { new IncrementalRunInput([ActivityInterface], []), new IncrementalRunInput([renamed], []) }; + // Act + var result = await GenerateIncrementalAsync(inputs, Options, cancellationToken); + + var first = GetGeneratedSources(result.Runs[0].RunResult).Keys.ToArray(); + var second = GetGeneratedSources(result.Runs[1].RunResult).Keys.ToArray(); + + // Assert + await Assert + .That(first.Any(static key => key.EndsWith(".Activity.g.cs", StringComparison.Ordinal))) + .IsTrue() + .Because("the first run generates the activity target"); + await Assert + .That(second.Any(static key => key.EndsWith(".Activity.g.cs", StringComparison.Ordinal))) + .IsTrue() + .Because("the renamed interface must still generate its activity target"); + await Assert + .That(first.Except(second, StringComparer.Ordinal).Any()) + .IsTrue() + .Because("the hint name of the old interface must not survive the rename"); + await Assert + .That(second.Except(first, StringComparer.Ordinal).Any()) + .IsTrue() + .Because("the renamed interface must generate under a new hint name"); + } + + /// + /// A global option (an MSBuild property read through the analyzer-config provider) must be observed on + /// every run, not captured once when the pipeline was built. The generation context resolves + /// LangVersion from it, so changing the property has to re-run that stage rather than resolve it + /// from a cached value. + /// + [Test] + public async Task Generate_AnalyzerConfigOptionChange_RerunsTheGenerationContext( + CancellationToken cancellationToken + ) + { + // Arrange + var inputs = new[] + { + new IncrementalRunInput([ActivityInterface], []), + new IncrementalRunInput([ActivityInterface], [("build_property.LangVersion", "12.0")]), + }; + // Act + var result = await GenerateIncrementalAsync(inputs, Options, cancellationToken); + + var reasons = GetStageReasons(result.Runs[1], "GetGenerationContext_TelemetryCapabilities"); + // Assert + await Assert.That(reasons).IsNotEmpty().Because("the generation context stage must run"); + await Assert + .That( + reasons.Any(static reason => + reason is IncrementalStepRunReason.Modified or IncrementalStepRunReason.New + ) + ) + .IsTrue() + .Because("a changed global option must re-resolve the generation context, not leave it cached"); + } + /// /// The union accessor plan is computed from the interface rather than from the owning target's own method records. /// That must stay a value-equatable model: a second run over unchanged sources re-runs nothing. @@ -319,12 +441,14 @@ public async Task Generate_UnchangedMultiTargetUnionInterface_AccessorPlanCaches CancellationToken cancellationToken ) { + // Act var result = await GenerateIncrementalAsync( [UnionSource, MultiTargetUnionInterface], Options, cancellationToken ); + // Assert await Assert .That(GetFrameworkReasons(result.Runs[0]).All(static reason => reason == IncrementalStepRunReason.New)) .IsTrue() @@ -349,14 +473,17 @@ public async Task Generate_UnrelatedChangeWithMultiTargetUnionInterface_OutputSt CancellationToken cancellationToken ) { + // Arrange var inputs = new[] { new IncrementalRunInput([UnionSource, MultiTargetUnionInterface], []), new IncrementalRunInput([UnionSource, MultiTargetUnionInterface, UnrelatedType], []), }; + // Act var result = await GenerateIncrementalAsync(inputs, Options, cancellationToken); var reasons = GetFrameworkReasons(result.Runs[1]); + // Assert await Assert.That(reasons).IsNotEmpty(); await Assert .That( From 844ddf864aab335cdbe82252d381b6bc627dcd77 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sun, 15 Nov 2026 00:34:18 +0000 Subject: [PATCH 07/12] test: bring the suites into AAA compliance Roughly 150 of the 531 tests carried no Arrange/Act/Assert markers at all, concentrated in the refactoring suites, and five tests marked the act and the arrangement with one combined 'Arrange / Act' line. Every braced test now carries // Act, and // Assert wherever the assertion is in its own body. The four snapshot refactoring suites stop at // Act: their assertion is the snapshot comparison inside CodeRefactoringTestBase. // Arrange appears where there is something to arrange; the tests whose first statement is the act are left without an empty section. The eight expression-bodied delegates in RegexValidationTests are unchanged - their AAA lives in the shared MatchAsync helper. --- src/tests/Repository.UnitTests/SkillTests.cs | 4 + .../TelemetryDiagnosticAnalyzerTests.cs | 18 +++ ...urceGeneratorActivityTests.AddException.cs | 4 +- ...oggingGen2Tests.LogPropertiesTransitive.cs | 4 +- ...ceGeneratorLoggingGen2Tests.TagProvider.cs | 2 +- .../MessageTemplates/RegexValidationTests.cs | 2 +- ...lemetryRefactoringProviderSnapshotTests.cs | 22 +++ ...urceToTelemetryRefactoringProviderTests.cs | 21 +++ ...terfaceRefactoringProviderSnapshotTests.cs | 18 +++ ...etryToInterfaceRefactoringProviderTests.cs | 21 +++ ...lemetryRefactoringProviderSnapshotTests.cs | 31 ++++ ...ggerToTelemetryRefactoringProviderTests.cs | 138 ++++++++++++++++++ ...lemetryRefactoringProviderSnapshotTests.cs | 24 +++ ...ricsToTelemetryRefactoringProviderTests.cs | 24 +++ 14 files changed, 327 insertions(+), 6 deletions(-) diff --git a/src/tests/Repository.UnitTests/SkillTests.cs b/src/tests/Repository.UnitTests/SkillTests.cs index fc8a32e1..c2eec152 100644 --- a/src/tests/Repository.UnitTests/SkillTests.cs +++ b/src/tests/Repository.UnitTests/SkillTests.cs @@ -24,8 +24,10 @@ public sealed class SkillTests : PurviewSkillTests [Test] public async Task SkillsFolder_ResolvesToThisRepository() { + // Act var catalogue = Evaluator.Catalogue; + // Assert await Assert .That(catalogue.FolderExists) .IsTrue() @@ -45,11 +47,13 @@ public async Task EverySkill_IsNamedForThisProduct() // Skills install into one flat directory per agent, shared with every skill a developer has // installed from anywhere. An unprefixed name collides with somebody else's, and the loser is // whichever installed second. + // Act var unprefixed = Evaluator .Catalogue.Skills.Select(skill => skill.FolderName) .Where(name => !name.StartsWith("purview-telemetry", StringComparison.Ordinal)) .ToArray(); + // Assert await Assert .That(unprefixed) .IsEmpty() diff --git a/src/tests/SourceGenerator.IntegrationTests/Analyzers/TelemetryDiagnosticAnalyzerTests.cs b/src/tests/SourceGenerator.IntegrationTests/Analyzers/TelemetryDiagnosticAnalyzerTests.cs index e1926603..0e0f5557 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Analyzers/TelemetryDiagnosticAnalyzerTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Analyzers/TelemetryDiagnosticAnalyzerTests.cs @@ -15,6 +15,7 @@ public async Task Analyze_GivenNestedGenericActivityInterface_RaisesGenericInter CancellationToken cancellationToken ) { + // Arrange const string code = """ public class Outer { @@ -25,8 +26,10 @@ public interface ITestActivities } """; + // Act var result = await AnalyzeAsync(code, new TelemetryAnalyzerTestOptions(), cancellationToken); + // Assert await Assert.That(result).HasDiagnostic("TSG1004"); } @@ -35,6 +38,7 @@ public async Task Analyze_GivenGenericActivityInterface_DoesNotRaiseGenericInter CancellationToken cancellationToken ) { + // Arrange const string code = """ [ActivitySource] public interface ITestActivities @@ -42,8 +46,10 @@ public interface ITestActivities } """; + // Act var result = await AnalyzeAsync(code, new TelemetryAnalyzerTestOptions(), cancellationToken); + // Assert await Assert.That(result).DoesNotHaveDiagnostic("TSG1004"); } @@ -52,6 +58,7 @@ public async Task Analyze_GivenDuplicateActivityMethodNames_RaisesDuplicateNameD CancellationToken cancellationToken ) { + // Arrange const string code = """ [ActivitySource] @@ -66,8 +73,10 @@ public interface ITestActivities """; + // Act var result = await AnalyzeAsync(code, new TelemetryAnalyzerTestOptions(), cancellationToken); + // Assert await Assert.That(result).HasDiagnostic("TSG1003"); } @@ -76,6 +85,7 @@ public async Task Analyze_GivenLogMethodReturningString_RaisesInvalidReturnDiagn CancellationToken cancellationToken ) { + // Arrange const string code = """ [Logger] @@ -87,8 +97,10 @@ public interface ITestLogger """; + // Act var result = await AnalyzeAsync(code, new TelemetryAnalyzerTestOptions(), cancellationToken); + // Assert await Assert.That(result).HasDiagnostic("TSG2021"); } @@ -98,6 +110,7 @@ public async Task Analyze_GivenMetricMethodReturningInt_RaisesInvalidReturnDiagn CancellationToken cancellationToken ) { + // Arrange const string code = """ [Meter("testing-meter")] @@ -109,14 +122,17 @@ public interface ITestMetrics """; + // Act var result = await AnalyzeAsync(code, new TelemetryAnalyzerTestOptions(), cancellationToken); + // Assert await Assert.That(result).HasDiagnostic("TSG4001"); } [Test] public async Task Analyze_GivenValidActivityInterface_RaisesNoDiagnostics(CancellationToken cancellationToken) { + // Arrange const string code = """ [ActivitySource] @@ -128,8 +144,10 @@ public interface ITestActivities """; + // Act var result = await AnalyzeAsync(code, new TelemetryAnalyzerTestOptions(), cancellationToken); + // Assert await Assert.That(result).HasNoDiagnostics(); } } diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.AddException.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.AddException.cs index 9a51bc49..7879d940 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.AddException.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.AddException.cs @@ -25,7 +25,7 @@ public async Task Generate_GivenActivitySource_EmitsAddExceptionWhenTheCompilati CancellationToken cancellationToken ) { - // Arrange / Act + // Act using var generationResult = await GenerateAsync(ActivitySourceWithEvent, cancellationToken: cancellationToken); // Assert @@ -49,7 +49,7 @@ public async Task Generate_GivenActivitySource_DoesNotDuplicateTheFrameworkExcep CancellationToken cancellationToken ) { - // Arrange / Act + // Act using var generationResult = await GenerateAsync(ActivitySourceWithEvent, cancellationToken: cancellationToken); // Assert: AddException writes exception.type/message/stacktrace itself. Emitting them too would either diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.LogPropertiesTransitive.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.LogPropertiesTransitive.cs index 5394e899..866b1856 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.LogPropertiesTransitive.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.LogPropertiesTransitive.cs @@ -40,7 +40,7 @@ public struct Coordinates [Test] public async Task Generate_GivenTransitiveLogProperty_FlattensNestedObject(CancellationToken cancellationToken) { - // Arrange / Act + // Act using var generationResult = await GenerateAsync(TransitiveLoggerSource, cancellationToken: cancellationToken); // Assert @@ -64,7 +64,7 @@ public async Task Generate_GivenTransitiveLogPropertyWithStructProperty_DoesNotE CancellationToken cancellationToken ) { - // Arrange / Act + // Act using var generationResult = await GenerateAsync(TransitiveLoggerSource, cancellationToken: cancellationToken); // Assert: `weather?.Coordinates?.Lat` does not compile - a non-nullable struct cannot take a `?`. diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.TagProvider.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.TagProvider.cs index ef5cd443..29656bf8 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.TagProvider.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.TagProvider.cs @@ -29,7 +29,7 @@ public static class WeatherTagProvider [Test] public async Task Generate_GivenTagProvider_EmitsProviderCallIntoLogState(CancellationToken cancellationToken) { - // Arrange / Act + // Act using var generationResult = await GenerateAsync(TagProviderLoggerSource, cancellationToken: cancellationToken); // Assert diff --git a/src/tests/SourceGenerator.IntegrationTests/MessageTemplates/RegexValidationTests.cs b/src/tests/SourceGenerator.IntegrationTests/MessageTemplates/RegexValidationTests.cs index 99ab01b1..f7a21f28 100644 --- a/src/tests/SourceGenerator.IntegrationTests/MessageTemplates/RegexValidationTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/MessageTemplates/RegexValidationTests.cs @@ -51,7 +51,7 @@ [NotNull] TestMessageTemplateHole[] holes static async Task MatchAsync(string template, TestMessageTemplateHole[] holes) { - // Arrange/ Act + // Act var matches = PropertyLibrary.MessageTemplateMatcher.Matches(template); // Assert diff --git a/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertActivitySourceToTelemetryRefactoringProviderSnapshotTests.cs b/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertActivitySourceToTelemetryRefactoringProviderSnapshotTests.cs index a715e6a6..18b6bba9 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertActivitySourceToTelemetryRefactoringProviderSnapshotTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertActivitySourceToTelemetryRefactoringProviderSnapshotTests.cs @@ -16,6 +16,7 @@ public sealed class ConvertActivitySourceToTelemetryRefactoringProviderSnapshotT [Test] public async Task Verify_SingleStartActivity_LiteralName(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics; @@ -32,12 +33,14 @@ public void Process(string orderId) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_SingleStartActivity_NoName(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics; @@ -54,12 +57,14 @@ public void Load() } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_SingleStartActivity_WithActivityKind(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics; @@ -76,12 +81,14 @@ public void SendRequest(string url) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_MultipleActivities_DifferentKinds(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics; @@ -108,6 +115,7 @@ public void ProcessBackground(string jobId) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } @@ -118,6 +126,7 @@ public void ProcessBackground(string jobId) [Test] public async Task Verify_PrimaryConstructor_ActivitySource(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics; @@ -132,12 +141,14 @@ public void Search(string query) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_InstanceField_ExpressionBodyCtor(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics; @@ -156,6 +167,7 @@ public void Send(string to, string subject) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } @@ -166,6 +178,7 @@ public void Send(string to, string subject) [Test] public async Task Verify_ActivityName_FromLiteral_CamelCase(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics; @@ -182,12 +195,14 @@ public void FetchFromCache(string key) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_ActivityName_FromLiteral_WithSeparator(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics; @@ -204,6 +219,7 @@ public void PickItem(string itemId) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } @@ -214,6 +230,7 @@ public void PickItem(string itemId) [Test] public async Task Verify_SameActivityName_TwoMethods(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics; @@ -235,12 +252,14 @@ public void Dequeue() } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_StaticFieldInit_FullNewExpression(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics; @@ -257,6 +276,7 @@ public void Fetch(string id) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } @@ -267,6 +287,7 @@ public void Fetch(string id) [Test] public async Task Verify_DocumentScope_TwoClassesInSameFile(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics; @@ -293,6 +314,7 @@ public void TrackQuery(string query) } """; + // Act await VerifyRefactoringAsync( code, Provider, diff --git a/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertActivitySourceToTelemetryRefactoringProviderTests.cs b/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertActivitySourceToTelemetryRefactoringProviderTests.cs index cc1c7cc6..312a5a5d 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertActivitySourceToTelemetryRefactoringProviderTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertActivitySourceToTelemetryRefactoringProviderTests.cs @@ -13,6 +13,7 @@ public async Task ComputeRefactorings_GivenClassWithoutActivitySource_ReturnsNoA CancellationToken cancellationToken ) { + // Arrange const string code = """ using System.Diagnostics; @@ -24,8 +25,10 @@ public void DoWork() { } } """; + // Act var actions = await GetRefactoringActionsAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(actions).IsEmpty(); } @@ -34,6 +37,7 @@ public async Task ComputeRefactorings_GivenActivitySourceFieldButNoStartActivity CancellationToken cancellationToken ) { + // Arrange const string code = """ using System.Diagnostics; @@ -47,8 +51,10 @@ public void DoWork() { } } """; + // Act var actions = await GetRefactoringActionsAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(actions).IsEmpty(); } @@ -61,6 +67,7 @@ public async Task ComputeRefactorings_GivenClassWithActivitySource_ReturnsAction CancellationToken cancellationToken ) { + // Arrange const string code = """ using System.Diagnostics; @@ -77,8 +84,10 @@ public void GetWeather(string city) } """; + // Act var actions = await GetRefactoringActionsAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(actions).IsNotEmpty(); await Assert.That(actions[0].Title).IsEqualTo("Convert ActivitySource to IWeatherServiceTracing"); } @@ -92,6 +101,7 @@ public async Task ApplyRefactoring_GivenSingleStartActivityCall_GeneratesTracing CancellationToken cancellationToken ) { + // Arrange const string code = """ using System.Diagnostics; @@ -108,8 +118,10 @@ public void GetWeather(string city) } """; + // Act var result = await ApplyRefactoringAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[ActivitySource]"); await Assert.That(result).Contains("IWeatherServiceTracing"); @@ -123,6 +135,7 @@ public async Task ApplyRefactoring_GivenStartActivityWithKind_GeneratesActivityA CancellationToken cancellationToken ) { + // Arrange const string code = """ using System.Diagnostics; @@ -139,8 +152,10 @@ public void ProcessPayment(string paymentId) } """; + // Act var result = await ApplyRefactoringAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[ActivitySource]"); await Assert.That(result).Contains("IPaymentServiceTracing"); @@ -152,6 +167,7 @@ public async Task ApplyRefactoring_GivenActivitySourceField_ReplacesFieldTypeWit CancellationToken cancellationToken ) { + // Arrange const string code = """ using System.Diagnostics; @@ -168,8 +184,10 @@ public void PlaceOrder(int orderId) } """; + // Act var result = await ApplyRefactoringAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("IOrderServiceTracing _activitySource"); await Assert.That(result).DoesNotContain("ActivitySource _activitySource"); @@ -180,6 +198,7 @@ public async Task ApplyRefactoring_GivenStartActivityCall_ReplacesCallWithInterf CancellationToken cancellationToken ) { + // Arrange const string code = """ using System.Diagnostics; @@ -196,8 +215,10 @@ public void ShipOrder(string trackingNumber) } """; + // Act var result = await ApplyRefactoringAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("_activitySource.ShipOrder("); await Assert.That(result).DoesNotContain(".StartActivity("); diff --git a/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertAllTelemetryToInterfaceRefactoringProviderSnapshotTests.cs b/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertAllTelemetryToInterfaceRefactoringProviderSnapshotTests.cs index ef061033..393d9cd5 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertAllTelemetryToInterfaceRefactoringProviderSnapshotTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertAllTelemetryToInterfaceRefactoringProviderSnapshotTests.cs @@ -16,6 +16,7 @@ public sealed class ConvertAllTelemetryToInterfaceRefactoringProviderSnapshotTes [Test] public async Task Verify_AllThreeTelemetryTypes(CancellationToken cancellationToken) { + // Arrange const string code = """ using System; using System.Diagnostics; @@ -51,6 +52,7 @@ public void CancelOrder(string orderId, Exception ex) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } @@ -61,6 +63,7 @@ public void CancelOrder(string orderId, Exception ex) [Test] public async Task Verify_LoggerOnly(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -79,12 +82,14 @@ public void LogAction(string userId, string action) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_ActivitySourceOnly(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics; @@ -101,12 +106,14 @@ public void Execute(string sql) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_MetricsOnly(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics.Metrics; @@ -132,6 +139,7 @@ public void RecordEvent(double latencyMs) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } @@ -142,6 +150,7 @@ public void RecordEvent(double latencyMs) [Test] public async Task Verify_LoggerAndMetrics(CancellationToken cancellationToken) { + // Arrange const string code = """ using System; using System.Diagnostics.Metrics; @@ -177,12 +186,14 @@ public void Fail(string paymentId, Exception ex) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_LoggerAndActivitySource(CancellationToken cancellationToken) { + // Arrange const string code = """ using System; using System.Diagnostics; @@ -210,12 +221,14 @@ public void Failed(string jobId, Exception ex) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_ActivitySourceAndMetrics(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics; using System.Diagnostics.Metrics; @@ -249,6 +262,7 @@ public void Miss(string key) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } @@ -259,6 +273,7 @@ public void Miss(string key) [Test] public async Task Verify_ComplexService_PrimaryConstructor(CancellationToken cancellationToken) { + // Arrange const string code = """ using System; using System.Diagnostics; @@ -301,6 +316,7 @@ public void IndexFailed(string productId, Exception ex) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } @@ -311,6 +327,7 @@ public void IndexFailed(string productId, Exception ex) [Test] public async Task Verify_DocumentScope_TwoClassesInSameFile(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics; using System.Diagnostics.Metrics; @@ -357,6 +374,7 @@ public void PlaceOrder(int orderId) } """; + // Act await VerifyRefactoringAsync( code, Provider, diff --git a/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertAllTelemetryToInterfaceRefactoringProviderTests.cs b/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertAllTelemetryToInterfaceRefactoringProviderTests.cs index 858d56bc..251a2ff5 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertAllTelemetryToInterfaceRefactoringProviderTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertAllTelemetryToInterfaceRefactoringProviderTests.cs @@ -13,6 +13,7 @@ public async Task ComputeRefactorings_GivenClassWithNoTelemetry_ReturnsNoActions CancellationToken cancellationToken ) { + // Arrange const string code = """ namespace Testing; @@ -22,8 +23,10 @@ public void DoWork() { } } """; + // Act var actions = await GetRefactoringActionsAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(actions).IsEmpty(); } @@ -34,6 +37,7 @@ public void DoWork() { } [Test] public async Task ComputeRefactorings_GivenClassWithILogger_ReturnsAction(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -52,8 +56,10 @@ public void GetWeather(string city) } """; + // Act var actions = await GetRefactoringActionsAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(actions).IsNotEmpty(); await Assert.That(actions[0].Title).IsEqualTo("Convert all telemetry to IWeatherServiceTelemetry"); } @@ -63,6 +69,7 @@ public async Task ComputeRefactorings_GivenClassWithActivitySource_ReturnsAction CancellationToken cancellationToken ) { + // Arrange const string code = """ using System.Diagnostics; @@ -79,8 +86,10 @@ public void GetWeather(string city) } """; + // Act var actions = await GetRefactoringActionsAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(actions).IsNotEmpty(); await Assert.That(actions[0].Title).IsEqualTo("Convert all telemetry to IWeatherServiceTelemetry"); } @@ -88,6 +97,7 @@ public void GetWeather(string city) [Test] public async Task ComputeRefactorings_GivenClassWithMetrics_ReturnsAction(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics.Metrics; @@ -107,8 +117,10 @@ public void GetWeather() } """; + // Act var actions = await GetRefactoringActionsAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(actions).IsNotEmpty(); await Assert.That(actions[0].Title).IsEqualTo("Convert all telemetry to IWeatherServiceTelemetry"); } @@ -122,6 +134,7 @@ public async Task ApplyRefactoring_GivenAllThreeTelemetryTypes_GeneratesCombined CancellationToken cancellationToken ) { + // Arrange const string code = """ using System.Diagnostics; using System.Diagnostics.Metrics; @@ -150,8 +163,10 @@ public void DoWork(string input) } """; + // Act var result = await ApplyRefactoringAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[ActivitySource]"); await Assert.That(result).Contains("[Logger]"); @@ -165,6 +180,7 @@ public async Task ApplyRefactoring_GivenILoggerOnly_GeneratesTelemetryInterfaceW CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -183,8 +199,10 @@ public void GenerateReport(string reportId) } """; + // Act var result = await ApplyRefactoringAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Logger]"); await Assert.That(result).Contains("IReportServiceTelemetry"); @@ -197,6 +215,7 @@ public async Task ApplyRefactoring_GivenActivitySourceOnly_GeneratesTelemetryInt CancellationToken cancellationToken ) { + // Arrange const string code = """ using System.Diagnostics; @@ -213,8 +232,10 @@ public void DoTracedWork(string id) } """; + // Act var result = await ApplyRefactoringAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[ActivitySource]"); await Assert.That(result).Contains("ITracingServiceTelemetry"); diff --git a/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertILoggerToTelemetryRefactoringProviderSnapshotTests.cs b/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertILoggerToTelemetryRefactoringProviderSnapshotTests.cs index c5cc3926..8b5d1bb5 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertILoggerToTelemetryRefactoringProviderSnapshotTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertILoggerToTelemetryRefactoringProviderSnapshotTests.cs @@ -16,6 +16,7 @@ public sealed class ConvertILoggerToTelemetryRefactoringProviderSnapshotTests : [Test] public async Task Verify_SingleLogInformation_NoParams(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -34,12 +35,14 @@ public void Run() } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_SingleLogInformation_WithParams(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -58,12 +61,14 @@ public void GetWeather(string city, int days) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_AllSixLogLevels(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -87,12 +92,14 @@ public void ProcessOrder(int orderId) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_LogError_WithException(CancellationToken cancellationToken) { + // Arrange const string code = """ using System; using Microsoft.Extensions.Logging; @@ -116,12 +123,14 @@ public void ProcessPayment(string paymentId) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_LogError_WithException_NoParams(CancellationToken cancellationToken) { + // Arrange const string code = """ using System; using Microsoft.Extensions.Logging; @@ -145,6 +154,7 @@ public void Save() } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } @@ -155,6 +165,7 @@ public void Save() [Test] public async Task Verify_GenericILogger_T(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -176,12 +187,14 @@ public void GenerateReport(string reportId) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_NonGenericILogger(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -200,12 +213,14 @@ public void LogAudit(string action) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_PropertyInjection(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -222,6 +237,7 @@ public void Notify(string userId, string message) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } @@ -232,6 +248,7 @@ public void Notify(string userId, string message) [Test] public async Task Verify_PrimaryConstructorInjection(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -246,12 +263,14 @@ public void UpdateStock(string sku, int delta) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_ExpressionBodyConstructor(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -270,6 +289,7 @@ public void Evict(string key) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } @@ -280,6 +300,7 @@ public void Evict(string key) [Test] public async Task Verify_DuplicateMessageTemplate_Disambiguates(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -299,12 +320,14 @@ public void Login(string userId, string ip) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_MultipleCallSites_SameMessage(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -328,6 +351,7 @@ public void Reship(string orderId) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } @@ -338,6 +362,7 @@ public void Reship(string orderId) [Test] public async Task Verify_LogWithExplicitLogLevel_DoesNotRefactor(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -358,13 +383,16 @@ public void Diagnose(string component, LogLevel level) // A variable LogLevel cannot be mapped to a specific generated attribute, so no // refactoring should be offered. + // Act var actions = await GetRefactoringActionsAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(actions).IsEmpty(); } [Test] public async Task Verify_MixedLogLevels_MultipleParams(CancellationToken cancellationToken) { + // Arrange const string code = """ using System; using Microsoft.Extensions.Logging; @@ -391,6 +419,7 @@ public void Fail(string txId, Exception ex) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } @@ -401,6 +430,7 @@ public void Fail(string txId, Exception ex) [Test] public async Task Verify_DocumentScope_TwoClassesInSameFile(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -431,6 +461,7 @@ public void PlaceOrder(int orderId) } """; + // Act await VerifyRefactoringAsync( code, Provider, diff --git a/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertILoggerToTelemetryRefactoringProviderTests.cs b/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertILoggerToTelemetryRefactoringProviderTests.cs index 5ead262f..224d8847 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertILoggerToTelemetryRefactoringProviderTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertILoggerToTelemetryRefactoringProviderTests.cs @@ -9,6 +9,7 @@ public sealed class ConvertILoggerToTelemetryRefactoringProviderTests : CodeRefa [Test] public async Task ComputeRefactorings_GivenClassWithoutILogger_ReturnsNoActions(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -20,8 +21,10 @@ public void DoWork() { } } """; + // Act var actions = await GetRefactoringActionsAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(actions).IsEmpty(); } @@ -30,6 +33,7 @@ public async Task ComputeRefactorings_GivenClassWithILoggerButNoLogCalls_Returns CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -48,8 +52,10 @@ public void DoWork() { } } """; + // Act var actions = await GetRefactoringActionsAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(actions).IsEmpty(); } @@ -62,6 +68,7 @@ public async Task ComputeRefactorings_GivenClassWithILoggerAndLogCalls_ReturnsAc CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -83,8 +90,10 @@ public void GetWeather(string city) } """; + // Act var actions = await GetRefactoringActionsAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(actions).IsNotEmpty(); await Assert.That(actions[0].Title).IsEqualTo("Convert ILogger to IWeatherServiceLogs"); } @@ -94,6 +103,7 @@ public async Task ApplyRefactoring_GivenSingleLogInformationCall_GeneratesInfoIn CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -115,8 +125,10 @@ public void GetWeather(string city) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Logger]"); await Assert.That(result).Contains("IWeatherServiceLogs"); @@ -130,6 +142,7 @@ public async Task ApplyRefactoring_GivenMultipleLogLevels_GeneratesCorrectAttrib CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -153,8 +166,10 @@ public void ProcessOrder(int orderId) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Trace(\"Trace {OrderId}\")]"); await Assert.That(result).Contains("[Debug(\"Debug {OrderId}\")]"); @@ -169,6 +184,7 @@ public async Task ApplyRefactoring_GivenLogCallWithException_IncludesExceptionPa CancellationToken cancellationToken ) { + // Arrange const string code = """ using System; using Microsoft.Extensions.Logging; @@ -192,8 +208,10 @@ public void ProcessPayment(string paymentId) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Error(\"Payment failed for {PaymentId}\")]"); await Assert.That(result).Contains("System.Exception"); @@ -206,6 +224,7 @@ public async Task ApplyRefactoring_GivenDuplicateLogLevel_DeduplicatesMethodName CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -225,8 +244,10 @@ public void Login(string userId, string ip) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); // "User {UserId} logged in" → UserLoggedIn // "User {UserId} logged in from {Ip}" → UserLoggedInFrom @@ -239,6 +260,7 @@ public async Task ApplyRefactoring_GivenILoggerGeneric_ReplacesFieldTypeWithInte CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -257,8 +279,10 @@ public void Generate(string reportName) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); // ILogger should be replaced with IReportServiceLogs await Assert.That(result).Contains("IReportServiceLogs _logger"); @@ -272,6 +296,7 @@ public async Task ApplyRefactoring_GivenLogCallWithNoParams_GeneratesMethodWithN CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -290,8 +315,10 @@ public void Check() } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("void HealthCheckStarted()"); } @@ -301,6 +328,7 @@ public async Task ApplyRefactoring_GivenLogMethodCallOnNonGenericILogger_Detects CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -319,8 +347,10 @@ public void Run(string name) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Logger]"); await Assert.That(result).Contains("ISimpleServiceLogs"); @@ -333,6 +363,7 @@ public async Task ApplyRefactoring_GivenLogCallReplacesTemplateArgs_RemovesMessa CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -351,8 +382,10 @@ public void Save(string key, int value) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); // The invocation should now be _logger.Saving(key, value) — no template string await Assert.That(result).Contains("_logger.Saving(key, value)"); @@ -361,6 +394,7 @@ public void Save(string key, int value) [Test] public async Task ApplyRefactoring_GivenGenericLogMethod_MapsToCorrectAttribute(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -379,8 +413,10 @@ public void Audit(string action) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Warning(\"Audit: {Action}\")]"); await Assert.That(result).Contains("void Audit(string action)"); @@ -389,6 +425,7 @@ public void Audit(string action) [Test] public async Task ApplyRefactoring_GivenNamespace_IncludesNamespaceInInterface(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -407,8 +444,10 @@ public void Notify(string message) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Logger]"); await Assert.That(result).Contains("INotificationServiceLogs"); @@ -419,6 +458,7 @@ public async Task ApplyRefactoring_GivenMultipleParams_GeneratesCorrectParamList CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -437,8 +477,10 @@ public void Search(string query, int page, bool isCached) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("query"); await Assert.That(result).Contains("page"); @@ -452,6 +494,7 @@ public void Search(string query, int page, bool isCached) [Test] public async Task ApplyRefactoring_GivenLogCallWithEventId_SkipsEventIdArgument(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -470,8 +513,10 @@ public void ProcessOrder(int orderId) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); // new EventId(1, "OrderProcessed") is not a literal int, so EventId is not embedded in attribute; // the message template IS embedded. @@ -485,6 +530,7 @@ public async Task ApplyRefactoring_GivenLogCallWithEventIdAndException_SkipsEven CancellationToken cancellationToken ) { + // Arrange const string code = """ using System; using Microsoft.Extensions.Logging; @@ -508,8 +554,10 @@ public void Ship(string trackingId) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Error(\"Shipping failed for {TrackingId}\")]"); await Assert.That(result).Contains("exception"); @@ -524,6 +572,7 @@ public void Ship(string trackingId) [Test] public async Task ApplyRefactoring_GivenLogLevelInformation_MapsToInfoAttribute(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -542,8 +591,10 @@ public void Refresh(string key) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Info(\"Refreshing cache for {Key}\")]"); await Assert.That(result).Contains("void RefreshingCacheFor(string key)"); @@ -552,6 +603,7 @@ public void Refresh(string key) [Test] public async Task ApplyRefactoring_GivenLogLevelNone_EmitsFallbackLogAttribute(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -570,8 +622,10 @@ public void Diag(string info) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Log("); await Assert.That(result).Contains("LogLevel.None"); @@ -586,6 +640,7 @@ public async Task ApplyRefactoring_GivenTemplateWithDestructuringPrefix_Extracts CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -604,8 +659,10 @@ public void Login(object user) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("void UserLoggedIn(object user)"); // The template is preserved verbatim in the attribute (including the {@…} prefix), @@ -619,6 +676,7 @@ public async Task ApplyRefactoring_GivenTemplateWithStringifyPrefix_ExtractsPara CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -637,8 +695,10 @@ public void Created(object product) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("void ProductCreated(object product)"); // The template is preserved verbatim in the attribute (including the {$…} prefix), @@ -652,6 +712,7 @@ public async Task ApplyRefactoring_GivenTemplateWithFormatSpecifier_ExtractsPara CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -670,8 +731,10 @@ public void Record(double value) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("void MetricValue(double value)"); } @@ -681,6 +744,7 @@ public async Task ApplyRefactoring_GivenTemplateWithAlignmentSpecifier_ExtractsP CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -699,8 +763,10 @@ public void Report(int count) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("void ItemCount(int count)"); } @@ -710,6 +776,7 @@ public async Task ApplyRefactoring_GivenExtraArgsWithNoPlaceholders_GeneratesArg CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -728,8 +795,10 @@ public void Dump(string label, int a, int b) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("label"); await Assert.That(result).Contains("arg1"); @@ -745,6 +814,7 @@ public async Task ApplyRefactoring_GivenClassWithNoNamespace_GeneratesInterfaceW CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -761,8 +831,10 @@ public void Run() } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Logger]"); await Assert.That(result).Contains("IGlobalServiceLogs"); @@ -778,6 +850,7 @@ public async Task ComputeRefactorings_GivenClassWithMultipleILoggerFields_Return CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -804,8 +877,10 @@ public void Work(string name) } """; + // Act var actions = await GetRefactoringActionsAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(actions).IsNotEmpty(); } @@ -814,6 +889,7 @@ public async Task ApplyRefactoring_GivenConstructorWithMatchingLoggerParam_Repla CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -832,8 +908,10 @@ public void Filter(string input) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("IFilterServiceLogs logger"); // non-logger param must be untouched @@ -849,6 +927,7 @@ public async Task ComputeRefactorings_GivenPrimaryConstructorWithILogger_Returns CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -863,8 +942,10 @@ public void GetWeather(string city) } """; + // Act var actions = await GetRefactoringActionsAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(actions).IsNotEmpty(); await Assert.That(actions[0].Title).IsEqualTo("Convert ILogger to IWeatherServiceLogs"); } @@ -874,6 +955,7 @@ public async Task ApplyRefactoring_GivenPrimaryConstructorWithGenericILogger_Gen CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -888,8 +970,10 @@ public void GetWeather(string city) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Logger]"); await Assert.That(result).Contains("IWeatherServiceLogs"); @@ -905,6 +989,7 @@ public async Task ApplyRefactoring_GivenPrimaryConstructorWithNonGenericILogger_ CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -919,8 +1004,10 @@ public void Run(string name) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Logger]"); await Assert.That(result).Contains("ISimpleServiceLogs"); @@ -934,6 +1021,7 @@ public async Task ApplyRefactoring_GivenPrimaryConstructorWithMultipleLoggers_Co CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -949,8 +1037,10 @@ public void THING() } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Logger]"); await Assert.That(result).Contains("IServicesLogs"); @@ -971,6 +1061,7 @@ public async Task ApplyRefactoring_GivenPrimaryConstructorWithMixedParams_OnlyRe CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -985,8 +1076,10 @@ public void ProcessOrder(int orderId) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("IOrderServiceLogs logger"); // Non-logger param must be untouched @@ -1002,6 +1095,7 @@ public async Task ComputeRefactorings_GivenClassWithILoggerProperty_ReturnsActio CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -1018,8 +1112,10 @@ public void GetWeather(string city) } """; + // Act var actions = await GetRefactoringActionsAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(actions).IsNotEmpty(); await Assert.That(actions[0].Title).IsEqualTo("Convert ILogger to IWeatherServiceLogs"); } @@ -1027,6 +1123,7 @@ public void GetWeather(string city) [Test] public async Task ApplyRefactoring_GivenILoggerProperty_ReplacesPropertyType(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -1043,8 +1140,10 @@ public void Generate(string reportName) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Logger]"); await Assert.That(result).Contains("IReportServiceLogs"); @@ -1058,6 +1157,7 @@ public async Task ApplyRefactoring_GivenInitOnlyILoggerProperty_ReplacesProperty CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -1074,8 +1174,10 @@ public void Audit(string action) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("IAuditServiceLogs Logger"); await Assert.That(result).DoesNotContain("ILogger"); @@ -1090,6 +1192,7 @@ public async Task ComputeRefactorings_GivenClassWithILoggerMethodParam_ReturnsAc CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -1104,8 +1207,10 @@ public void GetWeather(ILogger logger, string city) } """; + // Act var actions = await GetRefactoringActionsAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(actions).IsNotEmpty(); await Assert.That(actions[0].Title).IsEqualTo("Convert ILogger to IWeatherServiceLogs"); } @@ -1113,6 +1218,7 @@ public void GetWeather(ILogger logger, string city) [Test] public async Task ApplyRefactoring_GivenILoggerMethodParam_ReplacesParamType(CancellationToken cancellationToken) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -1127,8 +1233,10 @@ public void Save(ILogger logger, string key, int value) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Logger]"); await Assert.That(result).Contains("IDataServiceLogs"); @@ -1145,6 +1253,7 @@ public async Task ApplyRefactoring_GivenILoggerMethodParamAcrossMultipleMethods_ CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -1164,8 +1273,10 @@ public void FailWork(ILogger logger, string task) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Info(\"Starting {Task}\")]"); await Assert.That(result).Contains("[Error(\"Failed {Task}\")]"); @@ -1183,6 +1294,7 @@ public async Task ApplyRefactoring_GivenLiteralMessageTemplate_EmbedsTemplateInA CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -1201,8 +1313,10 @@ public void ProcessOrder(string orderId) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); // The message template must appear verbatim in the attribute argument. await Assert.That(result).Contains("[Info(\"Processing order {OrderId} for customer\")]"); @@ -1213,6 +1327,7 @@ public async Task ApplyRefactoring_GivenNonLiteralMessageTemplate_EmitsAttribute CancellationToken cancellationToken ) { + // Arrange // When the message template is stored in a variable, not a string literal, the refactoring // cannot statically determine the template value. The attribute should be emitted without // any template argument — the source generator will auto-generate a name from parameters. @@ -1236,9 +1351,11 @@ public void Process(string key) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); // Refactoring should still trigger because there are log calls. + // Assert await Assert.That(result).IsNotNull(); // The attribute must NOT contain a template string argument. await Assert.That(result).Contains("[Info]"); @@ -1249,6 +1366,7 @@ public async Task ApplyRefactoring_GivenAllLogLevelsWithTemplates_EmbedsCorrectT CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -1272,8 +1390,10 @@ public void Run(string name) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Trace(\"Trace: {Name}\")]"); await Assert.That(result).Contains("[Debug(\"Debug: {Name}\")]"); @@ -1288,6 +1408,7 @@ public async Task ApplyRefactoring_GivenLogLevelNoneWithTemplate_EmbedsTemplateI CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -1306,8 +1427,10 @@ public void Diag(string info) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); // Unmapped level → [Log(LogLevel.None, "Diag: {Info}")] await Assert.That(result).Contains("[Log("); @@ -1320,6 +1443,7 @@ public async Task ApplyRefactoring_GivenTemplateWithSpecialChars_EscapesTemplate CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -1338,8 +1462,10 @@ public void Log(string path) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); // Backslash in the original template must be escaped in the attribute string argument. await Assert.That(result).Contains("[Info("); @@ -1355,6 +1481,7 @@ public async Task ApplyRefactoring_GivenLiteralIntEventId_EmbedsEventIdInAttribu CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -1373,8 +1500,10 @@ public void CreateInvoice(string invoiceId) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); // Literal int EventId 42 must appear in the attribute before the template. await Assert.That(result).Contains("[Info(42, \"Creating invoice {InvoiceId}\")]"); @@ -1386,6 +1515,7 @@ public async Task ApplyRefactoring_GivenNewEventIdObject_OmitsEventIdFromAttribu CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -1404,8 +1534,10 @@ public void Ship(string trackingId) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); // new EventId(…) is not a plain literal, so no EventId in the attribute; template IS embedded. await Assert.That(result).Contains("[Info(\"Shipping {TrackingId}\")]"); @@ -1417,6 +1549,7 @@ public async Task ApplyRefactoring_GivenLiteralIntEventIdWithException_EmbedsEve CancellationToken cancellationToken ) { + // Arrange const string code = """ using System; using Microsoft.Extensions.Logging; @@ -1440,8 +1573,10 @@ public void ProcessPayment(string paymentId) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Error(7, \"Payment failed for {PaymentId}\")]"); await Assert.That(result).Contains("System.Exception exception"); @@ -1453,6 +1588,7 @@ public async Task ApplyRefactoring_GivenLogNoneWithLiteralEventId_EmitsEventIdBe CancellationToken cancellationToken ) { + // Arrange const string code = """ using Microsoft.Extensions.Logging; @@ -1471,8 +1607,10 @@ public void Audit(string action) } """; + // Act var result = await ApplyRefactoringAsync(code, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); // [Log] with both EventId and LogLevel must use LogAttribute(int eventId, LogLevel level, …) // so eventId (99) must appear before the LogLevel argument. diff --git a/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertMetricsToTelemetryRefactoringProviderSnapshotTests.cs b/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertMetricsToTelemetryRefactoringProviderSnapshotTests.cs index c39a7265..8a4ef35d 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertMetricsToTelemetryRefactoringProviderSnapshotTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertMetricsToTelemetryRefactoringProviderSnapshotTests.cs @@ -19,6 +19,7 @@ public sealed class ConvertMetricsToTelemetryRefactoringProviderSnapshotTests : [Test] public async Task Verify_Counter_AutoIncrement_LiteralOne(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics.Metrics; @@ -41,12 +42,14 @@ public void Handle(string path) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_Counter_WithVariable_NotAutoIncrement(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics.Metrics; @@ -69,12 +72,14 @@ public void ProcessBatch(int batchSize) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_Counter_WithKeyValuePairTags(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Collections.Generic; using System.Diagnostics.Metrics; @@ -100,12 +105,14 @@ public void RecordCall(string route, string method) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_Counter_WithTagList(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics.Metrics; @@ -135,6 +142,7 @@ public void RecordSearch(string query, string index, string tenant, bool cacheHi } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } @@ -145,6 +153,7 @@ public void RecordSearch(string query, string index, string tenant, bool cacheHi [Test] public async Task Verify_Histogram_BasicRecord(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics.Metrics; @@ -167,12 +176,14 @@ public void Record(double elapsedMs) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_Histogram_WithTag(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Collections.Generic; using System.Diagnostics.Metrics; @@ -196,6 +207,7 @@ public void RecordQuery(double durationMs, string operation) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } @@ -206,6 +218,7 @@ public void RecordQuery(double durationMs, string operation) [Test] public async Task Verify_UpDownCounter_Add(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics.Metrics; @@ -226,6 +239,7 @@ public ConnectionPool(IMeterFactory meterFactory) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } @@ -236,6 +250,7 @@ public ConnectionPool(IMeterFactory meterFactory) [Test] public async Task Verify_Mixed_Counter_And_Histogram(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics.Metrics; @@ -261,12 +276,14 @@ public void RecordRequest(double durationMs) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_Mixed_AllThreeInstrumentTypes(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics.Metrics; @@ -303,6 +320,7 @@ public void Dequeue(double processMs) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } @@ -313,6 +331,7 @@ public void Dequeue(double processMs) [Test] public async Task Verify_PrimaryConstructor_WithMeterFactory(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics.Metrics; @@ -328,12 +347,14 @@ public class $$StorageMetrics(IMeterFactory meterFactory) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } [Test] public async Task Verify_Counter_IntMeasurementType(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics.Metrics; @@ -356,6 +377,7 @@ public void RecordTasks(int count) } """; + // Act await VerifyRefactoringAsync(code, Provider, cancellationToken); } @@ -366,6 +388,7 @@ public void RecordTasks(int count) [Test] public async Task Verify_DocumentScope_TwoClassesInSameFile(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics.Metrics; @@ -404,6 +427,7 @@ public void RecordError() } """; + // Act await VerifyRefactoringAsync( code, Provider, diff --git a/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertMetricsToTelemetryRefactoringProviderTests.cs b/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertMetricsToTelemetryRefactoringProviderTests.cs index beb4fbd3..1961720c 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertMetricsToTelemetryRefactoringProviderTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Refactorings/ConvertMetricsToTelemetryRefactoringProviderTests.cs @@ -14,6 +14,7 @@ public sealed class ConvertMetricsToTelemetryRefactoringProviderTests : CodeRefa [Test] public async Task ComputeRefactorings_GivenClassWithoutMetrics_ReturnsNoActions(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics.Metrics; @@ -25,8 +26,10 @@ public void DoWork() { } } """; + // Act var actions = await GetRefactoringActionsAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(actions).IsEmpty(); } @@ -35,6 +38,7 @@ public async Task ComputeRefactorings_GivenMetricsFieldButNoAddCalls_ReturnsNoAc CancellationToken cancellationToken ) { + // Arrange const string code = """ using System.Diagnostics.Metrics; @@ -48,8 +52,10 @@ public void DoWork() { } } """; + // Act var actions = await GetRefactoringActionsAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(actions).IsEmpty(); } @@ -60,6 +66,7 @@ public void DoWork() { } [Test] public async Task ComputeRefactorings_GivenClassWithCounter_ReturnsAction(CancellationToken cancellationToken) { + // Arrange const string code = """ using System.Diagnostics.Metrics; @@ -81,8 +88,10 @@ public void GetWeather(string city) } """; + // Act var actions = await GetRefactoringActionsAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(actions).IsNotEmpty(); await Assert.That(actions[0].Title).IsEqualTo("Convert Metrics to IWeatherServiceMetrics"); } @@ -96,6 +105,7 @@ public async Task ApplyRefactoring_GivenCounterAddWithLiteralOne_GeneratesAutoCo CancellationToken cancellationToken ) { + // Arrange const string code = """ using System.Diagnostics.Metrics; @@ -117,8 +127,10 @@ public void HandleRequest() } """; + // Act var result = await ApplyRefactoringAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Meter]"); await Assert.That(result).Contains("IRequestServiceMetrics"); @@ -131,6 +143,7 @@ public async Task ApplyRefactoring_GivenCounterAddWithVariable_GeneratesCounterI CancellationToken cancellationToken ) { + // Arrange const string code = """ using System.Diagnostics.Metrics; @@ -152,8 +165,10 @@ public void ProcessBatch(int count) } """; + // Act var result = await ApplyRefactoringAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Meter]"); await Assert.That(result).Contains("IBatchServiceMetrics"); @@ -166,6 +181,7 @@ public async Task ApplyRefactoring_GivenHistogramRecord_GeneratesHistogramInterf CancellationToken cancellationToken ) { + // Arrange const string code = """ using System.Diagnostics.Metrics; @@ -187,8 +203,10 @@ public void RecordDuration(double ms) } """; + // Act var result = await ApplyRefactoringAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[Histogram]"); await Assert.That(result).Contains("ITimingServiceMetrics"); @@ -200,6 +218,7 @@ public async Task ApplyRefactoring_GivenUpDownCounter_GeneratesUpDownCounterInte CancellationToken cancellationToken ) { + // Arrange const string code = """ using System.Diagnostics.Metrics; @@ -219,8 +238,10 @@ public QueueService(Meter meter) } """; + // Act var result = await ApplyRefactoringAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("[UpDownCounter]"); await Assert.That(result).Contains("IQueueServiceMetrics"); @@ -231,6 +252,7 @@ public async Task ApplyRefactoring_GivenMetricsField_ReplacesFieldTypeWithInterf CancellationToken cancellationToken ) { + // Arrange const string code = """ using System.Diagnostics.Metrics; @@ -252,8 +274,10 @@ public void PlaceOrder() } """; + // Act var result = await ApplyRefactoringAsync(code, Provider, cancellationToken: cancellationToken); + // Assert await Assert.That(result).IsNotNull(); await Assert.That(result).Contains("IOrderServiceMetrics _orderCounter"); await Assert.That(result).DoesNotContain("Counter _orderCounter"); From 43569c0201de2e4253d424c8de327b6031d6fba7 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sun, 15 Nov 2026 00:35:18 +0000 Subject: [PATCH 08/12] build(deps): update Purview.BuildSdk to 2.1.0 and the framework packages Purview.BuildSdk 2.1.0 (global.json) rewrites its managed .editorconfig, which is why that file carries the new version marker; Purview.SourceGeneratorFramework and Purview.Skills.Testing move to 2.1.0, and the purview.build local tool to 0.9.1. Every one of these is a lower bound for the package this repository publishes, so the floor proved by just test-floor moves with them. --- .config/dotnet-tools.json | 2 +- .editorconfig | 2 +- Directory.Packages.props | 4 ++-- global.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index f216ffd9..73921e4c 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -10,7 +10,7 @@ "rollForward": false }, "purview.build": { - "version": "0.9.0", + "version": "0.9.1", "commands": [ "purview-build" ], diff --git a/.editorconfig b/.editorconfig index a9b39cb1..a1e00511 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# Purview.BuildSdk 2.0.0 - managed file, do not edit +# Purview.BuildSdk 2.1.0 - managed file, do not edit # # This file is written by Purview.BuildSdk and is replaced whenever the SDK version changes. The version # on the first line is how the SDK recognises a file it owns: a .editorconfig without that marker is diff --git a/Directory.Packages.props b/Directory.Packages.props index fc8d897c..47aad226 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,7 +2,7 @@ 5.9.0 1.83.0 - 2.0.0 + 2.1.0 1.20.1