From 08b0d04ae6a34ffaecd1bf3d96940e1262850cd7 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sat, 14 Nov 2026 18:30:36 +0000 Subject: [PATCH] feat: added C# 15 output options to SGF --- .agents/skills/add-catalogue-project/SKILL.md | 182 +++++ .agents/skills/add-new-element-shape/SKILL.md | 276 ++++++++ .../update_tmgrammars.py | 139 ++++ .../skills/audit-catalogue-projects/SKILL.md | 154 ++++ .agents/skills/changeset-generator/SKILL.md | 146 ++++ .../skills/docs-aggregation-rules/SKILL.md | 237 +++++++ .agents/skills/github-repo-metadata/SKILL.md | 89 +++ .../likec4-cli-codegen-regression/SKILL.md | 82 +++ .agents/skills/likec4-dsl/SKILL.md | 474 +++++++++++++ .../skills/likec4-dsl/evals/evals-public.json | 207 ++++++ .../skills/likec4-dsl/evals/grading-spec.json | 603 ++++++++++++++++ .../references/bridge-leanix-drawio.md | 42 ++ .agents/skills/likec4-dsl/references/cli.md | 168 +++++ .../likec4-dsl/references/configuration.md | 266 +++++++ .../likec4-dsl/references/deployment.md | 160 +++++ .agents/skills/likec4-dsl/references/diffs.md | 52 ++ .../likec4-dsl/references/dynamic-views.md | 455 ++++++++++++ .../skills/likec4-dsl/references/examples.md | 370 ++++++++++ .../references/identifier-validity.md | 90 +++ .../include-predicates-wildcards.md | 180 +++++ .agents/skills/likec4-dsl/references/model.md | 182 +++++ .../likec4-dsl/references/predicates.md | 123 ++++ .../references/relationships-bidirectional.md | 176 +++++ .../likec4-dsl/references/specification.md | 79 +++ .../references/style-tokens-colors.md | 150 ++++ .../likec4-dsl/references/troubleshooting.md | 101 +++ .agents/skills/likec4-dsl/references/views.md | 329 +++++++++ .agents/skills/likec4-gh-pr-triage/SKILL.md | 111 +++ .agents/skills/likec4-issue-repro/SKILL.md | 78 ++ .../likec4-project-config-workflow/SKILL.md | 66 ++ .../project-manifest-reference/SKILL.md | 150 ++++ .../purview-architecture-testing/SKILL.md | 5 +- .agents/skills/purview-architecture/SKILL.md | 3 +- .../references/contract-strategy.md | 2 +- .../references/project-responsibilities.md | 2 +- .../skills/purview-buildsdk-adoption/SKILL.md | 104 ++- .../purview-buildsdk-configuration/SKILL.md | 133 +++- .../references/properties.md | 81 ++- .../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 | 63 +- .../references/blazor.md | 27 +- .../references/detection.md | 36 +- .../purview-buildsdk-project-layout/SKILL.md | 13 +- .../SKILL.md | 31 +- .../SKILL.md | 229 ++++++ .../SKILL.md | 178 +++-- .../references/repository-root.md | 13 +- .../purview-buildsdk-skill-tests/SKILL.md | 5 +- .../references/authoring.md | 2 + .../skills/purview-buildsdk-testing/SKILL.md | 15 +- .../purview-component-compatibility/SKILL.md | 116 +++ .../references/lowest-compatible.md | 93 +++ .../references/purview-components.md | 60 ++ .../scripts/dependencies.mjs | 239 +++++++ .../purview-engineering-principles/SKILL.md | 39 +- .agents/skills/purview-org-harvest/SKILL.md | 197 ++++++ .../references/best-in-class.md | 77 ++ .../references/harvest-commands.md | 121 ++++ .../purview-org-harvest/scripts/inventory.mjs | 466 ++++++++++++ .agents/skills/purview-persistence/SKILL.md | 65 +- .../references/ef-core-integration.md | 83 +++ .../references/guid-generation.md | 26 +- .../references/postgresql.md | 44 ++ .../references/sqlserver.md | 49 +- .../references/well-known-identifiers.md | 93 +++ .../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 +++ .agents/skills/purview-telemetry/SKILL.md | 3 +- .agents/skills/site-validation-loop/SKILL.md | 120 ++++ .agents/skills/use-case-authoring/SKILL.md | 84 +++ .../SKILL.md | 182 +++++ .../scripts/capture-likec4-vscode-preview.sh | 254 +++++++ .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 + .config/dotnet-tools.json | 2 +- .editorconfig | 2 +- AGENTS.md | 24 +- CHANGELOG.md | 33 +- Justfile | 170 ++--- README.md | 10 +- docs/wiki/Getting-Started.md | 14 +- docs/wiki/Guide.md | 279 ++++---- docs/wiki/Home.md | 40 +- docs/wiki/Testing.md | 6 +- docs/wiki/_Sidebar.md | 36 +- docs/wiki/_redirects.yml | 17 + docs/wiki/{ => authoring}/Analyzers.md | 22 +- .../{ => authoring}/Attribute-Data-Models.md | 4 +- docs/wiki/{ => authoring}/Code-Writer.md | 61 +- .../{ => authoring}/Component-Failures.md | 0 .../{ => authoring}/Incremental-Pipeline.md | 4 +- docs/wiki/{ => authoring}/Type-Library.md | 4 +- .../incremental-pipeline-preferred.c4 | 31 + docs/wiki/{ => operations}/Packaging.md | 8 +- docs/wiki/{ => operations}/Performance.md | 4 +- docs/wiki/{ => operations}/Release-Flow.md | 11 +- docs/wiki/{ => platform}/DotNet-11.md | 6 +- docs/wiki/{ => testing}/Step-Cache-Tests.md | 0 docs/wiki/{ => testing}/Testing-TUnit.md | 2 +- global.json | 2 +- package.json | 2 +- skills-lock.json | 174 ++++- .../SKILL.md | 5 +- .../SKILL.md | 7 + .../purview-sourcegenerator-testing/SKILL.md | 2 +- .../AnalyzerReleases.Shipped.md | 14 +- .../AnalyzerReleases.Unshipped.md | 2 +- .../AttributeDataModelNamingAnalyzer.cs | 20 +- .../OrphanedFixableDiagnosticIdAnalyzer.cs | 2 +- .../PipelineModelDiscovery.cs | 4 +- .../PreferExtensionKeywordAnalyzer.cs | 2 +- .../PreferHashDefinesAnalyzer.cs | 2 +- ...PreferMinimalCodeWriterOverloadAnalyzer.cs | 2 +- .../PreferNullableContextOverloadAnalyzer.cs | 2 +- .../PreferPragmaDisableAnalyzer.cs | 2 +- .../PreferStructuredCodeWriterApiAnalyzer.cs | 2 +- ...eferStructuredCodeWriterIfBlockAnalyzer.cs | 2 +- ...erStructuredCodeWriterStatementAnalyzer.cs | 2 +- .../TypeLibraryValidationAnalyzer.cs | 16 +- ...inimalCodeWriterOverloadCodeFixProvider.cs | 60 ++ .../PreferPragmaDisableCodeFixProvider.cs | 115 +++ ...ucturedCodeWriterIfBlockCodeFixProvider.cs | 2 +- ...turedCodeWriterStatementCodeFixProvider.cs | 352 +++++++++ .../README.md | 2 +- .../AttributeDataModelGenerator.cs | 1 + .../Helpers/AttributeDataModelLibrary.cs | 4 +- .../Helpers/TypeLibraryModelLibrary.cs | 4 +- .../README.md | 4 +- .../MergeToolRunner.cs | 7 +- .../Assertions/AttributeDataAssertions.cs | 1 + .../Sdk/README.md | 2 +- .../AttributeArgument.cs | 7 +- .../AttributeArguments.cs | 2 +- .../EmittedAssembly.cs | 2 +- .../RoslynTestRunner.cs | 5 +- .../Sdk/README.md | 4 +- .../SourceGeneratorHelpers.cs | 2 +- .../SourceGeneratorFramework/Sdk/README.md | 29 +- .../SourceGeneratorFramework.csproj | 2 +- .../CodeWriter.DeclarationOverloads.cs | 85 +++ src/src/SourceGeneratorShared/CodeWriter.cs | 126 +++- .../Helpers/TypeHelpers.cs | 13 +- .../TypeDeclarationKind.cs | 10 + .../TypeDeclarationOptions.cs | 17 +- .../SourceGeneratorShared/TypeSyntaxFacts.cs | 2 +- .../Fixture.Generator/.editorconfig | 2 +- .../InstalledSkillTests.cs | 22 +- ...ucturedCodeWriterStatementAnalyzerTests.cs | 11 + ...lCodeWriterOverloadCodeFixProviderTests.cs | 62 ++ ...PreferPragmaDisableCodeFixProviderTests.cs | 58 ++ ...CodeWriterStatementCodeFixProviderTests.cs | 210 ++++++ .../LogAttributeDataTests.cs | 4 +- .../CodeQueryTests.cs | 6 +- .../CodeWriterTests.cs | 119 ++++ .../Helpers/TypeHelpersTests.cs | 34 + 186 files changed, 14270 insertions(+), 701 deletions(-) create mode 100644 .agents/skills/add-catalogue-project/SKILL.md create mode 100644 .agents/skills/add-new-element-shape/SKILL.md create mode 100644 .agents/skills/add-new-element-shape/update_tmgrammars.py create mode 100644 .agents/skills/audit-catalogue-projects/SKILL.md create mode 100644 .agents/skills/changeset-generator/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/likec4-cli-codegen-regression/SKILL.md create mode 100644 .agents/skills/likec4-dsl/SKILL.md create mode 100644 .agents/skills/likec4-dsl/evals/evals-public.json create mode 100644 .agents/skills/likec4-dsl/evals/grading-spec.json create mode 100644 .agents/skills/likec4-dsl/references/bridge-leanix-drawio.md create mode 100644 .agents/skills/likec4-dsl/references/cli.md create mode 100644 .agents/skills/likec4-dsl/references/configuration.md create mode 100644 .agents/skills/likec4-dsl/references/deployment.md create mode 100644 .agents/skills/likec4-dsl/references/diffs.md create mode 100644 .agents/skills/likec4-dsl/references/dynamic-views.md create mode 100644 .agents/skills/likec4-dsl/references/examples.md create mode 100644 .agents/skills/likec4-dsl/references/identifier-validity.md create mode 100644 .agents/skills/likec4-dsl/references/include-predicates-wildcards.md create mode 100644 .agents/skills/likec4-dsl/references/model.md create mode 100644 .agents/skills/likec4-dsl/references/predicates.md create mode 100644 .agents/skills/likec4-dsl/references/relationships-bidirectional.md create mode 100644 .agents/skills/likec4-dsl/references/specification.md create mode 100644 .agents/skills/likec4-dsl/references/style-tokens-colors.md create mode 100644 .agents/skills/likec4-dsl/references/troubleshooting.md create mode 100644 .agents/skills/likec4-dsl/references/views.md create mode 100644 .agents/skills/likec4-gh-pr-triage/SKILL.md create mode 100644 .agents/skills/likec4-issue-repro/SKILL.md create mode 100644 .agents/skills/likec4-project-config-workflow/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-component-compatibility/SKILL.md create mode 100644 .agents/skills/purview-component-compatibility/references/lowest-compatible.md create mode 100644 .agents/skills/purview-component-compatibility/references/purview-components.md create mode 100644 .agents/skills/purview-component-compatibility/scripts/dependencies.mjs create mode 100644 .agents/skills/purview-org-harvest/SKILL.md create mode 100644 .agents/skills/purview-org-harvest/references/best-in-class.md create mode 100644 .agents/skills/purview-org-harvest/references/harvest-commands.md create mode 100644 .agents/skills/purview-org-harvest/scripts/inventory.mjs create mode 100644 .agents/skills/purview-persistence/references/ef-core-integration.md create mode 100644 .agents/skills/purview-persistence/references/well-known-identifiers.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 create mode 100644 .agents/skills/vscode-extension-screenshot-evidence/SKILL.md create mode 100644 .agents/skills/vscode-extension-screenshot-evidence/scripts/capture-likec4-vscode-preview.sh 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 create mode 100644 docs/wiki/_redirects.yml rename docs/wiki/{ => authoring}/Analyzers.md (88%) rename docs/wiki/{ => authoring}/Attribute-Data-Models.md (98%) rename docs/wiki/{ => authoring}/Code-Writer.md (91%) rename docs/wiki/{ => authoring}/Component-Failures.md (100%) rename docs/wiki/{ => authoring}/Incremental-Pipeline.md (97%) rename docs/wiki/{ => authoring}/Type-Library.md (99%) create mode 100644 docs/wiki/diagrams/incremental-pipeline-preferred.c4 rename docs/wiki/{ => operations}/Packaging.md (98%) rename docs/wiki/{ => operations}/Performance.md (97%) rename docs/wiki/{ => operations}/Release-Flow.md (85%) rename docs/wiki/{ => platform}/DotNet-11.md (96%) rename docs/wiki/{ => testing}/Step-Cache-Tests.md (100%) rename docs/wiki/{ => testing}/Testing-TUnit.md (99%) create mode 100644 src/src/SourceGeneratorFramework.CodeFixers/PreferMinimalCodeWriterOverloadCodeFixProvider.cs create mode 100644 src/src/SourceGeneratorFramework.CodeFixers/PreferPragmaDisableCodeFixProvider.cs create mode 100644 src/src/SourceGeneratorFramework.CodeFixers/PreferStructuredCodeWriterStatementCodeFixProvider.cs create mode 100644 src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PreferMinimalCodeWriterOverloadCodeFixProviderTests.cs create mode 100644 src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PreferPragmaDisableCodeFixProviderTests.cs create mode 100644 src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PreferStructuredCodeWriterStatementCodeFixProviderTests.cs diff --git a/.agents/skills/add-catalogue-project/SKILL.md b/.agents/skills/add-catalogue-project/SKILL.md new file mode 100644 index 0000000..963fe0b --- /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/add-new-element-shape/SKILL.md b/.agents/skills/add-new-element-shape/SKILL.md new file mode 100644 index 0000000..9f45b51 --- /dev/null +++ b/.agents/skills/add-new-element-shape/SKILL.md @@ -0,0 +1,276 @@ +--- +name: add-new-element-shape +description: Use when you need to add a new element shape to the LikeC4 codebase. Trigger this skill whenever the user mentions adding a shape, creating a new shape, implementing a shape, or asks about how shapes work in LikeC4. Also trigger when the user provides a visual reference image and wants it turned into an element shape. Even if the user just says "new shape" or drops an image of a shape they want — use this skill. +disable-model-invocation: true +--- + +# Add New Element Shape — Agentic Workflow + +You are an agent implementing a new element shape in the LikeC4 codebase. Follow this workflow phase by phase. **Do not skip phases or reorder steps.** Pause at marked checkpoints and wait for user confirmation before continuing. + +--- + +## Phase 0: Gather Requirements + +### 0.1 Confirm inputs + +You need two things before starting: + +1. **A reference image** — a visual showing what the shape should look like. If the user hasn't provided one, ask: + > I need a visual reference for the shape — a screenshot, sketch, or example from another tool. Can you share one? + +2. **The shape name** — a single lowercase word, no hyphens or underscores (e.g., `hexagon`, `cloud`, `diamond`). If the user hasn't specified one, propose a name based on the reference image and confirm it. + +### 0.2 Understand the shape characteristics + +Before writing any code, determine: + +- **Outline type:** Is the shape rectangular, or does it have a custom path (curved, polygonal)? +- **Decorations:** Does it have sub-elements overlapping the text area (like `component`'s corner squares, or `browser`'s address bar)? +- **Closest existing shape:** Which existing shape is most similar? This will be your sketch host. + +Read the existing shapes to inform your decision: + +``` +view packages/diagram/src/base-primitives/element/ElementShape.tsx +``` + +Study the `ShapeSvg` function. Identify which `case` block is closest to what you need to build. + +--- + +## Phase 1: Sketch & Validate (interactive) + +The goal is to get a visual prototype in front of the user as fast as possible, without changing types, grammar, or any wiring files. + +### 1.1 Hijack an existing shape for sketching + +Pick the closest existing shape (from Phase 0) and **comment out its rendering**, replacing it with your new SVG implementation under the same `case`. This lets the shape render in a running app immediately. + +Use `str_replace` to edit `packages/diagram/src/base-primitives/element/ElementShape.tsx`: + +```typescript +case 'cylinder': { + // --- ORIGINAL (commented out for sketch) --- + // svg = + + // --- SKETCH: "YOUR_SHAPE" --- + svg = ( + + {/* Your new SVG implementation */} + + ) + break +} +``` + +Also update the corresponding `case` in `ShapeSvgOutline` if the shape has a non-rectangular outline. + +### SVG conventions + +- Main fill: no `data-likec4-fill` attribute (inherits from parent) +- `data-likec4-fill="mix-stroke"` for stroke-fill mix coloring +- `data-likec4-fill="fill"` for fill-colored sub-elements +- `strokeWidth={0}` for filled areas, `strokeWidth={2}` for outlined strokes +- For responsive sizing, switch on the `size` parameter (`'xs' | 'sm' | 'md' | 'lg' | 'xl'`) + +### Shape pattern reference + +| Pattern | Examples | Use when | +| ---------------------------- | ------------------------------------------------- | --------------------------------- | +| Rectangle + decorations | `'component'`, `'person'` | Rectangle with extras | +| Custom SVG path | `'cylinder'`, `'queue'`, `'bucket'`, `'document'` | Curved or non-rectangular outline | +| Box with inner header/chrome | `'browser'`, `'mobile'` | Container with UI elements | + +### ✅ CHECKPOINT: User validates the sketch + +Present the result to the user: + +> Here's the sketch of the `YOUR_SHAPE` shape. I've temporarily replaced `cylinder` to render it. Take a look and let me know if you'd like to adjust anything — proportions, stroke treatment, responsive sizing, sub-element placement, etc. + +**WAIT FOR USER APPROVAL.** Iterate on the sketch as many times as needed. Only proceed to Phase 2 after explicit confirmation. + +--- + +## Phase 2: Wire Everything Up (automated) + +Now that the visual is approved, register the shape across the codebase. This phase is fully mechanical — no user input needed. + +### 2.1 Core type definition + +**File:** `styled-system/preset/src/defaults/types.ts` + +Read the file, find the `ElementShapes` array, and add your shape name. This is the single source of truth — `ElementShape` type and PandaCSS conditions are derived from it. + +``` +view styled-system/preset/src/defaults/types.ts +``` + +Use `str_replace` to add the shape to the array. + +### 2.2 Langium grammar + +**File:** `packages/language-server/src/like-c4.langium` + +Find the `ElementShape returns string:` rule and add the new shape to the alternation. + +``` +view packages/language-server/src/like-c4.langium +``` + +### 2.3 TextMate grammars (syntax highlighting × 3) + +Run the helper script to update all three tmLanguage files at once: + +```bash +python3 /path/to/skill/scripts/update_tmgrammars.py YOUR_SHAPE +``` + +The script updates: + +- `packages/vscode/likec4.tmLanguage.json` +- `apps/playground/likec4.tmLanguage.json` +- `apps/docs/likec4.tmLanguage.json` + +**Verify** the script succeeded by spot-checking one file: + +``` +view packages/vscode/likec4.tmLanguage.json +``` + +### 2.4 Move sketch to proper case block + +Go back to `packages/diagram/src/base-primitives/element/ElementShape.tsx`: + +1. **Restore** the original shape you hijacked — uncomment its code and remove your sketch from its `case`. +2. **Add** a new `case 'YOUR_SHAPE':` block in `ShapeSvg` with the approved SVG. +3. **Add** a new `case 'YOUR_SHAPE':` in `ShapeSvgOutline` — or let it fall through to the `default` (rounded rect) if the shape is rectangular. + +### 2.5 Icon mapping + +**File:** `packages/diagram/src/context/IconRenderer.tsx` + +Read the file, find the `ShapeIcons` object, and add a mapping. The icon must be from `@tabler/icons-react` and be a `ForwardRefExoticComponent`. Choose an icon that visually represents the shape. Fallback: `IconRectangularPrism`. + +### 2.6 Export generators (× 4) + +For each generator file, read it, find the shape switch, and add a case. If there's no native equivalent in the target format, fall through to `rectangle`. + +| File | Format | +| --------------------------------------------------- | -------- | +| `packages/generators/src/drawio/generate-drawio.ts` | DrawIO | +| `packages/generators/src/puml/generate-puml.ts` | PlantUML | +| `packages/generators/src/mmd/generate-mmd.ts` | Mermaid | +| `packages/generators/src/d2/generate-d2.ts` | D2 | + +For each one: `view` the file → find the switch → `str_replace` to add the case. + +### 2.7 CompletionProvider test + +**File:** `packages/language-server/src/lsp/CompletionProvider.spec.ts` + +Find the `expectedItems` array for shape completions and add the shape in the same order as the Langium grammar. + +### 2.8 Conditional steps + +Evaluate these based on your shape's characteristics from Phase 0. **Skip if not needed:** + +**Shape-specific padding** (only if decorations overlap text area): + +- File: `styled-system/preset/src/recipes/elementNodeData.ts` +- Add `_shapeYourShape: { paddingLeft: ... }` (condition name is auto-generated from `ElementShapes`) + +**Shape-specific CSS parts** (only if custom sub-elements need their own styles): + +- File: `styled-system/preset/src/recipes/elementShape.ts` +- Add part definition + styling in the `svg` variant + +**Layout engine sizing** (only if non-standard bounding box): + +- File: `packages/layouts/src/graphviz/DotPrinter.ts` +- Add case in `addNode` method with adjusted width/margin + +### 2.9 E2E test (recommended) + +**File:** `e2e/src/likec4/views.c4` + +Add a style rule using the new shape: + +``` +style some_element { + shape YOUR_SHAPE +} +``` + +--- + +## Phase 3: Generate, Test, Verify (automated) + +Run these commands in order and report results: + +```bash +pnpm generate # Regenerate Langium parser + styled-system types +``` + +If `pnpm generate` fails, check your grammar syntax in the `.langium` file. + +```bash +pnpm typecheck +``` + +If typecheck fails, look at the errors — `nonexhaustive()` will tell you exactly which switch statements are missing the new shape. Fix each one. + +```bash +pnpm test +``` + +Snapshot files will auto-update: + +- `packages/generators/src/mmd/__snapshots__/generate-mmd.spec.ts.snap` +- `packages/generators/src/puml/__snapshots__/generate-puml.spec.ts.snap` + +```bash +pnpm build +``` + +### Self-healing + +If `pnpm typecheck` reports missing switch cases (via `nonexhaustive()`), this is the type system telling you which files you missed. Read the error, find the file, add the missing case. Re-run `pnpm typecheck` until clean. + +--- + +## Phase 4: Changeset & Summary + +### 4.1 Create changeset + +```bash +pnpm changeset +``` + +Select affected packages (at minimum `@likec4/diagram` and `likec4`), choose `patch`, description: "Add new `YOUR_SHAPE` element shape". + +### 4.2 Report to user + +Summarize what was done: + +> ✅ The `YOUR_SHAPE` shape is fully implemented. Here's what I changed: +> +> **Core:** types.ts, like-c4.langium, ElementShape.tsx, IconRenderer.tsx +> **Syntax:** 3 tmLanguage files +> **Exports:** DrawIO, PlantUML, Mermaid, D2 +> **Tests:** CompletionProvider.spec.ts, e2e/views.c4 +> **Conditional:** [list any conditional files that were edited, or "none needed"] +> +> All checks pass: generate ✅ typecheck ✅ test ✅ build ✅ + +--- + +## Architecture Notes + +- **`ElementShape` type** is derived from the `ElementShapes` array in `types.ts` — this is the single source of truth. +- **PandaCSS conditions** (`_shapeYourShape`) are auto-generated from `ElementShapes` via `styled-system/preset/src/conditions.ts:18-21`. +- **Generated files** (under `packages/language-server/src/generated/`, `styled-system/styles/dist/`) are rebuilt by `pnpm generate`. Never edit manually. +- **`schemas/likec4-config.schema.json`** is auto-generated from types. Do not edit manually. +- **`nonexhaustive()`** from `@likec4/core` enforces exhaustive switch coverage — TypeScript errors guide you to missing cases. +- **Shape names** must be single lowercase words (no hyphens or underscores). +- **Shape icons** must be `ForwardRefExoticComponent` from `@tabler/icons-react`. diff --git a/.agents/skills/add-new-element-shape/update_tmgrammars.py b/.agents/skills/add-new-element-shape/update_tmgrammars.py new file mode 100644 index 0000000..93793a2 --- /dev/null +++ b/.agents/skills/add-new-element-shape/update_tmgrammars.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +Update all three LikeC4 TextMate grammar files to include a new element shape. + +Usage: + python3 update_tmgrammars.py + +Example: + python3 update_tmgrammars.py hexagon + +This script finds the shapes regex pattern in each tmLanguage.json file and +adds the new shape to the alternation. It inserts after 'rectangle' by default, +keeping the pattern organized. +""" + +import json +import re +import sys +from pathlib import Path + +TMGRAMMAR_FILES = [ + "packages/vscode/likec4.tmLanguage.json", + "apps/playground/likec4.tmLanguage.json", + "apps/docs/likec4.tmLanguage.json", +] + +# The regex pattern that matches shape keywords in tmLanguage files. +# It looks like: \b(rectangle|person|browser|...)\\b +SHAPES_PATTERN = re.compile( + r"\\\\b\(([^)]+)\)\\\\b" +) + + +def find_repo_root() -> Path: + """Walk up from cwd to find the repo root (has package.json with 'likec4').""" + current = Path.cwd() + for parent in [current, *current.parents]: + pkg = parent / "package.json" + if pkg.exists() and "likec4" in pkg.read_text().lower(): + return parent + # Fallback: try common locations + for candidate in [Path("/home/claude/likec4"), Path("/repo"), Path.cwd()]: + if (candidate / "package.json").exists(): + return candidate + print("ERROR: Could not find LikeC4 repo root. Run from within the repo.", file=sys.stderr) + sys.exit(1) + + +def update_tmgrammar(filepath: Path, shape: str) -> bool: + """ + Update a single tmLanguage.json file to include the new shape. + Returns True if the file was modified, False if shape already exists. + """ + text = filepath.read_text(encoding="utf-8") + + # Check if shape already present + if re.search(rf"\b{re.escape(shape)}\b", text): + print(f" SKIP {filepath.name} — '{shape}' already present") + return False + + # Find and update the shapes pattern + # We look for the pattern that matches shape keywords + # The pattern in JSON looks like: "\\b(rectangle|person|...)\\b" + # We insert the new shape after 'rectangle' + def add_shape(match): + full_match = match.group(0) + shapes_str = match.group(1) + shapes = [s.strip() for s in shapes_str.split("|")] + + # Only modify if this looks like the shapes pattern (has known shape keywords) + known_shapes = {"rectangle", "person", "browser", "mobile", "cylinder", "storage", "queue"} + if len(known_shapes & set(shapes)) < 3: + return full_match + + if shape in shapes: + return full_match + + # Insert after 'rectangle' + idx = shapes.index("rectangle") if "rectangle" in shapes else 0 + shapes.insert(idx + 1, shape) + + return full_match.replace(match.group(1), "|".join(shapes)) + + new_text = SHAPES_PATTERN.sub(add_shape, text) + + if new_text == text: + # Try a more lenient pattern for JSON-escaped regex + # Some files may use single backslash escaping + alt_pattern = re.compile(r"\\b\(([^)]+)\)\\b") + new_text = alt_pattern.sub(add_shape, text) + + if new_text == text: + print(f" WARN {filepath.name} — could not find shapes pattern to update", file=sys.stderr) + return False + + filepath.write_text(new_text, encoding="utf-8") + print(f" OK {filepath.name} — added '{shape}'") + return True + + +def main(): + if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + + shape = sys.argv[1].lower().strip() + + # Validate shape name + if not shape.isalpha(): + print(f"ERROR: Shape name must be a single lowercase word (got '{shape}')", file=sys.stderr) + sys.exit(1) + + root = find_repo_root() + print(f"Repo root: {root}") + print(f"Adding shape: '{shape}' to tmLanguage files\n") + + modified = 0 + errors = 0 + + for relpath in TMGRAMMAR_FILES: + filepath = root / relpath + if not filepath.exists(): + print(f" MISS {relpath} — file not found", file=sys.stderr) + errors += 1 + continue + try: + if update_tmgrammar(filepath, shape): + modified += 1 + except Exception as e: + print(f" ERR {relpath} — {e}", file=sys.stderr) + errors += 1 + + print(f"\nDone: {modified} files updated, {errors} errors") + if errors > 0: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/audit-catalogue-projects/SKILL.md b/.agents/skills/audit-catalogue-projects/SKILL.md new file mode 100644 index 0000000..90328d7 --- /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/changeset-generator/SKILL.md b/.agents/skills/changeset-generator/SKILL.md new file mode 100644 index 0000000..1f9b6b8 --- /dev/null +++ b/.agents/skills/changeset-generator/SKILL.md @@ -0,0 +1,146 @@ +--- +name: changeset-generator +description: + Generates changeset files based on changes. Use when user asks to create a changeset or at the end of a feature implementation, when the user asks to wrap up or finalize changes +disable-model-invocation: true +--- + +> AGENTS.md is the canonical source for shared LikeC4 repository instructions, including changeset policy. This file is a task-specific wrapper for generating changeset files. + +Generate changeset files based on current branch changes or staged files. Use AGENTS.md for shared LikeC4 changeset policy, including when a changeset is needed, release type, package exclusions, and summary style. + +# 1. Identify Changes + +## Step 1: Detect change source + +Determine where to read changes from, in this priority order: + +```bash +# First: check for staged changes +STAGED=$(git diff --staged --name-only) + +if [ -n "$STAGED" ]; then + echo "Using staged changes" +else + # Second: check for branch changes vs main + BRANCH_DIFF=$(git diff --name-only main...HEAD 2>/dev/null) + + if [ -n "$BRANCH_DIFF" ]; then + echo "Using branch changes vs main" + else + # Third: check for uncommitted changes + UNCOMMITTED=$(git diff --name-only) + echo "Using uncommitted changes" + fi +fi +``` + +Use whichever source has changes. If none have changes, tell the user and stop. + +## Step 2: Gather context + +For **staged changes:** + +```bash +git diff --staged --name-only # changed files +git diff --staged --stat # change size overview +git diff --staged -- '*.ts' '*.tsx' # actual code diff for key files +``` + +For **branch changes:** + +```bash +git diff --name-only main...HEAD # changed files +git log --oneline main..HEAD # commit list +git log --format="%s%n%b" main..HEAD # commit messages for intent +``` + +Read the actual diff for non-trivial files when commit messages are vague — file names alone are often insufficient for writing a good summary. + +**Conventional Commits triage hints:** + +- `feat:` / `fix:` — usually needs a changeset +- `chore:` / `test:` / `refactor:` — usually skip unless user-facing + +# 2. Map Files to Packages + +Group changed files by their package: + +- `packages//*` → read `packages//package.json` for the package name +- `apps//*` → read `apps//package.json` +- `styled-system//*` → read `styled-system//package.json` + +Apply the changeset policy in AGENTS.md to decide whether each changed package has public impact. If no packages remain after filtering, tell the user there are no user-facing changes and stop. + +# 3. Generate Summary + +Follow the summary policy in AGENTS.md. Reference issues when relevant, for example `Fixes [#123](https://github.com/likec4/likec4/issues/123)`. + +# 4. Create Changeset File + +Use the release type required by AGENTS.md for every selected package. + +## File naming + +Derive the filename from the summary — lowercase, hyphens, descriptive: + +- `add-cloud-shape.md` +- `fix-diagram-zoom-reset.md` +- `element-notes-feature.md` + +## File format + +Create at `.changeset/.md`: + +```markdown +--- +'': patch +'': patch +--- + + +``` + +## Validate before writing + +Before creating the file, verify each package name exists: + +```bash +# For each package in your changeset, confirm it's real +cat packages//package.json | grep '"name"' +``` + +If a package name doesn't match, fix it before writing the changeset. + +# 5. Confirm with User + +Show the user the changeset content and file path before or after writing. If something looks off, iterate. + +# Examples + +## Good: + +```markdown +--- +'@likec4/diagram': patch +--- + +Add reset manual layout button with tooltip guidance +``` + +```markdown +--- +'@likec4/core': patch +'@likec4/diagram': patch +--- + +First iteration of element notes feature: + +- Add notes property to elements +- Display visual indicator on diagrams +``` + +# Notes + +- Combine multiple packages in one changeset if they're part of the same feature +- If a branch has multiple independent features, suggest creating separate changesets for each diff --git a/.agents/skills/docs-aggregation-rules/SKILL.md b/.agents/skills/docs-aggregation-rules/SKILL.md new file mode 100644 index 0000000..b5e646c --- /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 0000000..c346bc1 --- /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/likec4-cli-codegen-regression/SKILL.md b/.agents/skills/likec4-cli-codegen-regression/SKILL.md new file mode 100644 index 0000000..cf874ef --- /dev/null +++ b/.agents/skills/likec4-cli-codegen-regression/SKILL.md @@ -0,0 +1,82 @@ +--- +name: likec4-cli-codegen-regression +description: Use when changing or reviewing LikeC4 CLI code generation, exporters, output file naming, include-path codegen behavior, or `likec4 gen` commands. Trigger for bugs around `--outdir`, generated files escaping output folders, dot/d2/mermaid/plantuml output, or external include-path views. +--- + +# LikeC4 CLI Codegen Regression + +Protect `likec4 gen` output contracts with focused tests. The most important invariant is that multi-file generators write every emitted view under the requested output directory. + +## Ground rules + +- Start in `packages/likec4/src/cli/codegen`. +- Add a regression test before implementation when fixing a bug. +- Test every affected multi-file format, not only the format that exposed the issue. +- Treat external include-path views and Windows paths as first-class edge cases. + +## Output-path invariants + +For `likec4 gen dot`, `d2`, `mermaid` or `mmd`, and `plantuml`: + +- `--outdir` or `-o` defines the containment root for all generated files. +- Source-relative view paths must never escape the output root. +- Absolute source paths, parent-relative paths, and Windows drive-root paths must not create files outside `--outdir`. +- A view coming from an external include path should still be emitted under `--outdir`. +- Define and assert the resulting output naming contract for escaped or external source paths; containment-only checks are not enough. +- Single-file generators such as model, react, or webcomponent should keep their existing `--outfile` behavior. + +## Regression matrix + +Cover the smallest matrix that proves the contract: + +| Case | Expected result | +| ----------------------------------------- | ----------------------------------------------------------- | +| view in project folder | file under `--outdir` | +| view in nested project folder | nested file under `--outdir` | +| view from external include path | file under `--outdir`, not beside the included source | +| source path with `..` relation to project | sanitized or re-rooted under `--outdir` | +| Windows drive-style source path | no drive-root escape such as `C:` output outside `--outdir` | +| absolute source path | no absolute-root escape outside `--outdir` | + +Formats to consider: + +- `.dot` +- `.d2` +- `.mmd` +- `.puml` + +## Test shape + +Use a temp workspace fixture: + +```text +workspace/ + project-a/ + likec4.config.ts + project-view.c4 + shared/ + included-view.c4 + out/ +``` + +Configure `project-a` to include `../shared`. Run each generator with `--outdir workspace/out`. + +Assert: + +- expected files exist inside `workspace/out` +- escaped or external source paths use the explicitly chosen output naming contract +- no generated file exists under `project-a`, `shared`, or the temp workspace root outside `out` +- all generated paths pass an `isInside(outdir, filepath)` style check + +If the real fixture cannot produce absolute or Windows drive-style `sourcePath` values, extract a small path-resolution helper and cover those cases with pure unit tests. + +## Focused commands + +```bash +pnpm --filter likec4 test -- codegen +pnpm --filter likec4 typecheck +pnpm exec dprint check packages/likec4/src/cli/codegen +git diff --check +``` + +If touching shared path utilities, also run the package tests that own those utilities. diff --git a/.agents/skills/likec4-dsl/SKILL.md b/.agents/skills/likec4-dsl/SKILL.md new file mode 100644 index 0000000..43762f8 --- /dev/null +++ b/.agents/skills/likec4-dsl/SKILL.md @@ -0,0 +1,474 @@ +--- +name: likec4-dsl +description: Use when working with `.c4`/`.likec4` files or LikeC4 CLI/config questions where exact DSL/CLI syntax is required, especially for strict command/snippet-first answers, validate/export flags, predicates `*`/`_`/`**`, deployment snippets, dynamic views, or relationship extension matching. +--- + +# LikeC4 DSL Skill + +Architecture-as-code tool. Describe systems in `.c4`/`.likec4` files and LikeC4 generates interactive diagrams. + +## Rules + +1. **Projects** - it is possible to have multiple likec4 projects in a workspace, project is determined by presence of a config file (`.likec4rc`, `likec4.config.{ts,js,json}`). LikeC4 files belong to the project of the nearest config file in the directory hierarchy. +2. **Top-level statements** — only `import`, `specification`, `model`, `deployment`, `views`, `global`, `diff` are allowed. Blocks can repeat, but at least one per file must be present. +3. **Multi-file merge** — Top-level blocks across files are merged. For example, `model { ... }` blocks present in multiple files, parsed separately, and then merged into a single model. +4. **Strings** — `'single'`, `"double"` — all support multi-line. Escape quotes with backslash: `\'` or `\"`. In a `title`, escape `\/` to include a literal `/` without it being read as a view-folder separator. +5. **Markdown** — properties like `summary`/`description`/`notes` can contain Markdown. Use triple quotes `'''` or `"""`. Begin a new line after opening quotes and indent Markdown content for better formatting and syntax highlighting. Markdown can link to elements and views: `[[cloud.api]]`, `[[cloud.api|API]]`, `[[view:index]]` (see "Element links in Markdown" below). +6. **Comments** — `// single line` and `/* multi-line */` comments supported anywhere. +7. **Identifier** — letters, digits, hyphens, underscores only. No dots (dots are FQN separators). Can't start with a digit. Examples: `customer`, `payment-service`, `frontendApp`, `queue-1`. **Critical:** `payment-api` is valid; `payment.api` is NOT an identifier (dots separate FQN hierarchy). See `references/identifier-validity.md`. +8. **FQN** — Fully Qualified Name (FQN) is a dot-separated path to an element, MUST be unique within the project. Examples: `customer`, `saas.backend.payment-service.paymentsApi`, `infra.eu.zone1.node1`. +9. **References** — LikeC4 has lexical scoping with hoisting, nested scope may shadow outer, like in JavaScript. That scope does **not** carry across files: even with imports/includes in the same project, cross-file references must use full FQNs. Tiny reminder: `backend.api` does not survive a file boundary; across files write the full path such as `cloud.backend.api`. + +## Response Discipline (critical for evals) + +- If prompt says **"minimal"**, **"paste-ready"**, **"strict"**, **"exact"**, or requires a specific **first line**, output exactly **one final command/snippet/verdict token first** (no alternatives, no fallback variants, no extra preamble). +- Do **not** add unrequested `title`, labels, alternate snippets, or long explanations unless explicitly asked. +- Prefer exact requested tokens/phrases in the first line when the prompt requires strict phrasing. +- For strict command prompts, avoid ambiguous wording like "equivalent command" unless prompt explicitly asks for alternatives. +- If the task is snippet-first or command-first, a prose-only answer is a failure even if the explanation is knowledgeable. + +## CLI Canonical Contracts (anti-substitution guardrails) + +- Validate family: use `likec4 validate` (never substitute with `check`, `lint`, or `build`). +- Export family: use `likec4 export` (never substitute with other command families). +- Validation flags contract for strict evals: `--json --no-layout --file ... `. +- Multi-file validation contract: repeat `--file` once per edited `.c4` / `.likec4` source file. +- Export output flags contract: prefer `--outdir` or `-o` (avoid invented aliases). + +## Exact Syntax Guardrails (high-signal only) + +### Deployment snippets + +- If the prompt asks for **named deployment instances**, use `IDENTIFIER = instanceOf ELEMENT_ID`. +- Do **not** substitute anonymous `instanceOf ELEMENT_ID` lines when naming is required. +- If the prompt asks for a full fixture, keep the minimal executable structure: `specification`, `model`, `deployment`, and `views`. + +### Relationship-extension matchers + +- Relationship identity is matched by **source + target + kind (+ title when needed)**. +- If typed relationships exist, omitting `KIND` is wrong for strict disambiguation prompts. +- If multiple relationships share source/target/kind, include the title in the matcher. +- Do not "simplify" a typed matcher to `extend SOURCE -> TARGET ...` when the prompt is testing exact relationship identity. + +Triage anchor when typed alternatives coexist: + +```likec4 +// Existing relationships +api -[async]-> queue "publishes" +api -[sync]-> queue "publishes" + +// ✅ Correct: exact relationship selected +extend api -[async]-> queue "publishes" { metadata { retries "3" } } + +// ⚠️ Ambiguous: kind omitted, async vs sync both match source/target/title family +extend api -> queue "publishes" { metadata { retries "3" } } + +// ❌ Wrong: selects the other relationship +extend api -[sync]-> queue "publishes" { metadata { retries "3" } } +``` + +Compact ambiguity rule: + +- **Ambiguous** — kind omitted while multiple typed relationships share the same source/target/title family and you are only explaining why the matcher is underspecified. +- **Wrong** — kind omitted when the task asks for the **final** matcher/snippet for a specific async/sync relationship. + +## Workflow + +1. (Required) Find existing or create new project config (section below). Directory with project config defines the scope for all LikeC4 files in that directory and subdirectories. Ask user if you are uncertain about the scope. +2. (Required) Find existing or create new `specification { ... }`, this enables what kinds of elements/deployments/relationships/tags you can use. See Specification section below. +3. Architecture elements and relationships are defined in `model { ... }` block. See Model section below. +4. Deployment topology is defined in `deployment { ... }` block. See Deployment section below. +5. Views (diagrams) are defined in `views { ... }` block. See Views section below. +6. After editing LikeC4 files, validate with the CLI + +## Generate → Self-check → Finalize + +For strict command/snippet prompts, keep a compact loop: + +1. **Generate** only the requested final command/snippet. +2. **Self-check** quickly: + +- exact command family / required flags / repeated `--file` count +- snippet-first or first-line contract satisfied +- predicate semantics (`*`, `_`, `**`) stated precisely +- scope / FQN correctness +- deployment naming requirement satisfied +- relationship matcher specificity (`kind`, `title` when needed) +- dynamic view exactness: return arrows, chain form, single parallel block when requested, flow-control block placement (`alt` branches, no nested `parallel`, try/catch/finally order) +- Markdown links: `[[fqn]]` only inside `'''`/`"""`, full FQN that exists in the model, `\\|` as label separator inside tables + +3. **Finalize** by fixing in place (no extra alternatives unless explicitly requested). + +Before final answer, verify the required tokens are literally present when the prompt depends on them (examples: `--no-layout`, `instanceOf`, `variant sequence`, `global predicate`, `-[async]->`, `parallel {`, `alt {`, `when`, `try {`, `<-`). + +Never claim CLI execution happened unless it actually ran. + +## Validation + +```bash + likec4 validate --json --no-layout --file +``` + +Runtime launchers are equivalent for this command family: + +```bash +npx likec4 validate --json --no-layout --file +bunx likec4 validate --json --no-layout --file +pnpm dlx likec4 validate --json --no-layout --file +``` + +- `--json` — structured output (stdout), logging goes to stderr +- `--no-layout` — skip layout drift checks (faster, only syntax+semantic) +- `--file ` — use with `--json` to scope results to the edited files. Without `--json`, text output still prints all diagnostics. Repeat once per edited source file. +- `--max-warnings ` — fail (`valid: false`, exit code 1) when there are more than `n` warnings; `0` fails on any warning. Without it, warnings never fail validation. +- `` — path to the project directory +- There is **no** `likec4 check` command; use `likec4 validate`. + +For evals/gradings/executions, be **runner-tolerant** (`npx`/`bunx`/`pnpm dlx`), and judge correctness by subcommand + flags + project scope. + +If workspace already has `likec4` as a dependency, check its version from package.json and ensure it is at least 1.53.0. If pinning is needed, use the active runner (`npx`/`bunx`/`pnpm dlx`) with `likec4@1.53.0`. + +Example output: + +```json +{ + "valid": false, + "errors": [ + { + "message": "...", + "file": "/abs/path.c4", + "line": 5, + "range": { "start": { "line": 5, "character": 2 }, "end": { "line": 5, "character": 20 } } + } + ], + "warnings": [], // same shape as errors + "maxWarnings": null, // value of --max-warnings, null when not given + "stats": { + "totalFiles": 100, // Total number of files in the project + "totalErrors": 500, // Total number of errors in the project + "totalWarnings": 3, // Total number of warnings in the project + "filteredFiles": 1, // Number of files that match the --file filter + "filteredErrors": 1, // Number of errors in the filtered files + "filteredWarnings": 0 // Number of warnings in the filtered files + } +} +``` + +Broken specification/model in a large project can cascade into lots of errors across all files. Always use `--file` to focus on the files you edited. If `filteredErrors` is 0 but `totalErrors` is high, your files are clean but something else in the project is broken (not your problem). Selfcheck that `filteredFiles` matches the number of files you passed to `--file`. + +Field semantics (must be explicit in answers): + +- `filteredFiles`: count of files actually included by repeated `--file` filters +- `filteredErrors`: errors in the filtered subset only +- `totalErrors`: errors across the full project model +- `filteredWarnings`: warnings in the filtered subset only; `--max-warnings` limits this count + +Example edge case: if you pass 3 files but one is `likec4.config.json`, `filteredFiles` may be `2` because config JSON is not a `.c4`/`.likec4` source file for DSL validation. + +## Export PNG flags (precision) + +Canonical output directory flags: + +- `--outdir` (long form) +- `-o` (short form) + +Do not invent flags like `--out-dir`. Depending on LikeC4 version, `--output` may appear as compatibility alias; prefer `--outdir`/`-o` for deterministic answers. + +Full CLI reference → `references/cli.md` + +## Canonical Snippets for High-Variance Families + +Use these as exactness anchors when the prompt is testing syntax, not broad explanation. + +### `predicateGroup` reusable predicate + +```likec4 +global { + predicateGroup core-services { + include cloud.* where kind is service + exclude * where tag is #deprecated + } +} + +views { + view service-overview { + global predicate core-services + } +} +``` + +### Deployment fixture with named instances + +```likec4 +deployment { + vm appVm { + primary = instanceOf cloud.api + secondary = instanceOf cloud.api + } +} +``` + +### Scoped include semantics + +```likec4 +views { + view backend of cloud.backend { + include * + include -> cloud.backend + } +} +``` + +Interpretation anchor: in a scoped view, `include *` means the scoped element plus its **direct children** as the base include set; neighbors can still appear through scoped relationship visibility. + +### Metadata predicates + +```likec4 +include * where metadata.region is "eu" +include * where metadata.regions is "eu" +exclude * where not metadata.owner +exclude * -> * where metadata.protocol is "http" +exclude * -> * where target.metadata.zone is "restricted" +``` + +Truth card: + +- Array containment: `metadata.regions is "eu"` matches if the array contains `"eu"`. +- Scope: unqualified `metadata.*` filters the matched element or relationship itself; `source.metadata.*` / `target.metadata.*` filters endpoints. +- Deployment views: instance tags are cumulative; instance metadata replaces logical metadata as a whole when present, otherwise logical metadata is the fallback. + +### Deployment-view styling guardrail + +For strict repair prompts about deployment views, the safe answer is **local `style ... {}` inside the deployment view**. + +| Need | Prefer | Avoid as the answer | +| -------------------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Style one deployment view | `deployment view prod { include prod.** style prod._ { color primary } }` | `deployment view prod { include prod.** with { color primary } }` | +| Reuse styling in a deployment-view fix | local `style ... {}` rules in that deployment view | `global style theme` | + +Mini-reminder: in deployment views, treat `include ... with {}` and `global style ...` as unsupported repair patterns. Use a local `style ... {}` rule inside the deployment view instead. + +### Element links in Markdown (`[[fqn]]`) + +```likec4 +model { + cloud = system 'Cloud' { + ui = container 'Web UI' + api = container 'API' { + description ''' + Called by [[cloud.ui]] and [[cloud.ui|the frontend]]. + See [[view:index]] for context. + Literal brackets: \\[[not a link]] + + | Caller | Protocol | + | --------------------- | -------- | + | [[cloud.ui\\|Web UI]] | HTTPS | + ''' + } + } +} +``` + +- `[[cloud.ui]]` — element link, rendered with the element title +- `[[cloud.ui|UI]]` — element link with custom label `UI` +- `[[view:index]]` — view link, rendered with the view title +- `\\[[` — literal `[[`, not a link +- `[[cloud.ui\\|UI]]` — labelled link inside a Markdown table cell + +Truth card: + +- Only in Markdown values (`'''…'''` / `"""…"""`): `description`, `summary`, `notes`, `title`, `technology`, metadata values. Plain `'…'` / `"…"` strings, inline code and fenced code blocks are not scanned. +- Target is an **absolute FQN** in the **current project**: no short names, no `this`, no imported elements. `view:` is the only namespace; any other `prefix:` is reserved. +- Escaping follows DSL strings (`\\` → `\` before Markdown sees it): literal `[[` is `\\[[`; inside tables write `\\|`, a bare `|` splits the cell. +- Missing element/view or unknown `prefix:` → **warning**, not error. `likec4 validate` lists it under `warnings` but stays `valid: true` unless `--max-warnings` is given (`--max-warnings 0` fails on any warning); editors show it as an LSP diagnostic. +- Exports and MCP replace links with the title (or label); the DSL generator keeps `[[…]]`. + +Agent guidance: in descriptions prefer `[[fqn]]` over retyping an element's name (title stays in sync, readers can click it); link only FQNs that exist in the model; use `\\|` in tables. + +## LikeC4 Project Configuration + +Config file (`likec4.config.json`, `.likec4rc`, or `likec4.config.{ts,js}`) defines a project. Its location sets the project scope (LikeC4 files belong to the project of the nearest config file in the directory hierarchy). + +```json +{ + "$schema": "https://likec4.dev/schemas/config.json", + "name": "my-project", + "title": "Project Title" +} +``` + +Key options: `name` (required, unique ID in the workspace), `title` (display name) +Full reference → `references/configuration.md` + +## Specification + +Defines all named vocabularies: element kinds, deployment node kinds, relationship kinds, tags, and custom color tokens. Must appear before those kinds are used in `model` or `deployment` blocks. + +Key reminders: all definitions are global across files; duplicate kind/tag identifiers cause a validation error; specification changes trigger a full project re-parse — keep it in a dedicated `spec.c4` file. + +Full syntax, options per kind, and worked example → `references/specification.md` + +## Model + +Hierarchical structure of elements and relationships. Elements have a kind (from specification), a unique identifier within their parent, and optional properties and nested elements. + +Key reminders: `this`/`it` aliases the current element in nested relationships; cross-file references require full FQN; parent-child direct relationships are forbidden; `extend FQN { }` merges tags, metadata, and links into an existing element without redefining it. + +Full syntax, extend patterns, property table, and worked example → `references/model.md` + +## Style + +Style properties control visual appearance: `color`, `shape`, `border`, `opacity`, `size`, `padding`, `textSize`, `sizing`, `icon`, `iconColor`, `iconSize`, `iconPosition`, `multiple`. `sizing fixed` draws all elements of the same `size` with identical width/height (content shrinks to fit); default `auto` treats `size` as a minimum. Relationship style extends this with `line`, `head`, and `tail` arrow shapes. + +Full color token table, all shape values, border/opacity/size tokens, icon pack prefixes (`aws:`, `azure:`, `gcp:`, `tech:`, `bootstrap:`), and correct usage patterns → `references/style-tokens-colors.md` + +To discover available icons, use the CLI: `likec4 list-icons` (text, one per line). Filter by group with `--group `. Icon groups and approximate counts: `aws` (~307), `azure` (~614), `gcp` (~216), `tech` (~2000), `bootstrap` (~2051) (see `references/cli.md` for details). + +## Deployment + +Maps logical model elements to physical infrastructure nodes using `instanceOf`. Uses `deploymentNode` kinds from specification. Inherits all logical model relationships automatically; additional deployment-level relationships can be defined inline. + +Named vs. anonymous instances, multi-environment fixture, deployment relationships, and deployment filter metadata rules → `references/deployment.md` + +## Views + +Three view types: element views (`view id` or `view id of element`), dynamic views (`dynamic view id`), deployment views (`deployment view id`). View properties: `title`, `description`, `metadata`, `link`. + +Include/exclude predicates, view-level style rules, groups, `autoLayout`, `extends`, `navigateTo`, and global predicate groups → `references/views.md` + +## Quick Decision Trees + +### "I need incoming relationship predicates" + +```text +Need inbound relation selection? +├─ From any source to target element → `include -> target` +├─ From explicit wildcard source → `include * -> target` +└─ Include both directions around X → `include -> X ->` +``` + +`include -> X` and `include * -> X` are related but not interchangeable in all contexts; prefer the exact form requested by user/eval. + +### Scoped Predicate Truth Card (`*`, `_`, `**`) + +| Selector | One-line truth | Typical use | +| ----------- | --------------------------------------------------------------------------------- | ----------------------------------- | +| `parent.*` | Direct children of `parent` only | Show immediate structure | +| `parent._` | Direct children of `parent` that have relationships with accumulated result | Keep only connected direct children | +| `parent.**` | Recursive descendants of `parent` that have relationships with accumulated result | Explore connected deep descendants | + +Hard rule: do not describe `*` as recursive; do not describe `_` as wildcard-all; do not drop relationship-condition semantics for `_` / `**`. + +### "I need to create a diagram/view or show a flow or sequence" + +```text +What kind of diagram? +├─ Interaction flow / sequence → Dynamic View +├─ Infrastructure / deployment → Deployment View +├─ From architecture model → Element View +│ ├─ Primary element known → Scoped view: `view name of element { ... }` +│ └─ Extend existing view → `view name extends other { ... }` +└─ Other → `view name { ... }` +``` + +### "I need to style ..." + +```text +Styling? +├─ Style element(s) in a view → view `style` rule, see `references/views.md` +├─ Style element(s) in some views, but not all +│ ├─ views in same file → local view rule, see `references/views.md` +│ └─ views in different files → global view rule, see `references/views.md` +├─ Style element globally → property inside element definition, see Model section +├─ Style all elements of a kind → property inside kind specification, see Specification section +├─ Style by tag → view rule, see `references/views.md` +├─ Style relationship(s) in a view → view rule, see `references/views.md` +├─ Style relationship globally → property inside relationship definition, see Model section +├─ Style all relationships of a kind → property inside kind specification, see Specification section +├─ Reuse same styles across views → see `references/views.md` +``` + +### "I need to organize across files" + +```text +Multi-file project? +├─ Import from another project → import { backend } from 'shared-project' (project name, not a file path) +├─ Cross-file lookup → short names do not inherit lexical/container scope across files; use full FQN +├─ Extend element → extend cloud.backend { service newSvc "New" } +├─ Extend relationship → extend cloud -> amazon { metadata { ... } } +├─ Metadata merge → Duplicate keys become arrays +├─ Organize views → views "Use Cases" { ... } (folder label) +└─ All blocks are mergeable across files +``` + +### "I need to show a flow or sequence" + +```text +Flow / sequence diagram? +├─ Basic steps → source -> target "title" +├─ Response / backward → source <- target "returns" +├─ Parallel actions → one `parallel { ... }` block (also: `par { ... }`) +├─ Optional / repeated steps → `opt { ... }` / `loop { ... }` (optional title each) +├─ Mutually exclusive branches → `alt { when '...' { } else '...' { } }` +├─ Success + error handling → `try { } catch { } finally { }` +├─ Chained steps → customer -> frontend "x" -> backend "y" +├─ Step with notes → step { notes 'Markdown content' } +├─ Link to another view → step { navigateTo other-view } +├─ Sequence variant → dynamic view name { variant sequence } +└─ Full reference → references/dynamic-views.md +``` + +Flow-control blocks (experimental) — all take an optional title and nest to any depth, except `parallel`/`par` which cannot nest. `when`/`if`/`else` are valid **only** directly inside `alt`; `loop`/`opt`/`parallel`/`try` cannot be a direct child of `alt` (nest them inside a branch). See `references/dynamic-views.md`. + +Return-arrow precision: + +- If the prompt asks for **response arrows back out**, prefer `<-` steps rather than replacing them with new forward arrows. +- If the prompt asks for a body on a specific hop in a chain, attach the block only to that hop. +- If the prompt asks for one fan-out, keep sibling actions in one `parallel { ... }` block instead of multiple one-step parallel blocks. + +## Anti-Patterns to Avoid in Strict Prompts + +| Anti-pattern | Why it fails | Correct behavior | +| ------------------------------------------------------------------ | ----------------------------------------------------------------------------- | --------------------------------------------------------- | +| Substituting command families (`check`/`build`) for `validate` | Breaks exact command contract | Keep `likec4 validate` | +| Inventing/guessing flags | Creates non-portable invalid commands | Use canonical documented flags only | +| Multiple alternative snippets for one strict ask | Reduces precision; fails strict-output grading | Output one final answer unless alternatives are requested | +| Extending typed relationship without kind/title in ambiguous graph | Can target wrong relationship | Match with source + target + kind (+ title when needed) | +| Short or guessed FQN in `[[…]]`, e.g. `[[api]]` | Broken link is only a warning; validate passes unless `--max-warnings` is set | Use an existing full FQN: `[[cloud.backend.api]]` | + +## Common Mistakes & Debugging + +When a model errors or an eval answer seems wrong, load `references/troubleshooting.md` which contains: + +- **Syntax errors** — identifier format (dots forbidden in identifiers), unknown kinds, duplicate FQNs, malformed `where` predicates +- **Model & Hierarchy** — broken FQN references, parent-child relationship constraint, cross-file visibility +- **View Predicates** — `*` vs `**` confusion, missing neighbor elements, `WHERE` case sensitivity +- **Deployment** — `instanceOf` FQN resolution, undefined `deploymentNode` kinds +- **Dynamic Views** — flow-control blocks (`parallel`/`opt`/`loop`/`alt`/`try`), `alt`-branch placement rules, no nested `parallel`, response arrow symmetry, exact `variant sequence` keyword +- **Validation & Import** — config not found, import path resolution, upstream error cascade +- **Performance** — spec in separate file, splitting large views +- **5-step debugging workflow** — validation-first with `--file`, FQN integrity, predicate isolation, spec-first validation +- **7 Skill Best Practices** — response discipline, relationship disambiguation, FQN usage, safe editing rules + +## Reference Index + +Load a reference file when the task involves the corresponding topic. Claude reads SKILL.md first; these files are loaded on demand only when needed. + +| File | Purpose — load when... | +| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `references/specification.md` | Writing/editing `specification { }` blocks, defining element/deploymentNode/relationship/tag/color kinds | +| `references/model.md` | Writing/editing `model { }` blocks, element hierarchy, relationships, `extend` patterns, property names, `[[fqn]]` links | +| `references/deployment.md` | Writing/editing `deployment { }` blocks, `instanceOf`, named instances, multi-environment topology, deployment metadata filters | +| `references/style-tokens-colors.md` | Applying colors, shapes, icons, or relationship line styles; need exact token names | +| `references/views.md` | Writing views, include/exclude rules, style rules in views, groups, autoLayout, global predicates | +| `references/predicates.md` | Complex `where` conditions, metadata filters, `with` overrides, global predicate groups, reusable predicates | +| `references/include-predicates-wildcards.md` | Wildcard confusion suspected (`*` vs `_` vs `**`); need exact scoped-view semantics | +| `references/dynamic-views.md` | Writing dynamic views: steps, return arrows, chained steps, flow-control blocks (`parallel`/`opt`/`loop`/`alt`/`try`), `variant sequence` | +| `references/identifier-validity.md` | Identifier vs FQN confusion; "dots in names" errors; understanding FQN construction | +| `references/relationships-bidirectional.md` | Bidirectional relationship syntax and `<->` view predicate patterns | +| `references/diffs.md` | Planned model changes (`diff` blocks), applying or discarding them, safe delete of elements | +| `references/bridge-leanix-drawio.md` | LeanIX bridge · `drawio --profile leanix` · round-trip · mapping · MCP vs bridge · sync/artifacts/managed cells | +| `references/cli.md` | Full CLI reference: serve, build, export, codegen, mcp, format; flag disambiguation | +| `references/configuration.md` | Project config options, multi-project setup, include/exclude paths, generators | +| `references/examples.md` | Compact real-world examples: extend, groups, globals, dynamic views, deployment, rank | +| `references/troubleshooting.md` | Errors, unexpected output, eval failures — 6 error tables, 5-step debug workflow, 7 best practices | diff --git a/.agents/skills/likec4-dsl/evals/evals-public.json b/.agents/skills/likec4-dsl/evals/evals-public.json new file mode 100644 index 0000000..ef6b578 --- /dev/null +++ b/.agents/skills/likec4-dsl/evals/evals-public.json @@ -0,0 +1,207 @@ +{ + "skill_name": "likec4-dsl", + "artifact_type": "evals-public", + "schema_version": 2, + "evals": [ + { + "id": 0, + "prompt": "I just edited `projects/template/system-model.c4` and I only want syntax and semantic validation for that file, not layout drift. What exact LikeC4 CLI command should I run from the repo root, and which JSON fields tell me whether only my file failed versus the whole project being broken?", + "files": [] + }, + { + "id": 1, + "prompt": "I’m creating `projects/template/likec4.config.json` and I want that project to reuse shared specs from `projects/shared/`. Show a minimal JSON config snippet and explain how LikeC4 decides which project a `.c4` file belongs to when multiple config files exist in the workspace.", + "files": [] + }, + { + "id": 2, + "prompt": "I want to export only views matching `overview*` as dark PNGs into `./images`, flattened, for the current project. What exact LikeC4 CLI command should I run? (If a project path is optional in this context, make that explicit.)", + "files": [] + }, + { + "id": 3, + "prompt": "Give me a minimal LikeC4 dynamic view snippet that renders as a UML-style sequence for `client -> gateway -> orders -> db`, and includes response arrows back out to the client.", + "files": [] + }, + { + "id": 4, + "prompt": "I only want `cloud.backend.*` highlighted in one view while everything else stays muted. Do not move this styling into the model or specification. Answer in two parts: (1) one explicit sentence saying where this styling belongs, and (2) one minimal LikeC4 view snippet that uses `include *`, `style * { color muted }`, and a dedicated rule for `cloud.backend.*`.", + "files": [] + }, + { + "id": 5, + "prompt": "Assume `deploymentNode vm` already exists and `cloud.api` already exists in the logical model. Show a minimal deployment snippet that creates two separately named instances of `cloud.api` inside one VM node. Do not use two anonymous `instanceOf cloud.api` lines; I specifically need two named instances in the same node.", + "files": [] + }, + { + "id": 6, + "prompt": "In a view scoped `of cloud.backend`, I want to show only the direct children of `cloud.backend` that have at least one relationship with the elements already included in the view — not ALL children. Which predicate should I use: `cloud.backend.*`, `cloud.backend._`, or `cloud.backend.**`? Give me a minimal view snippet using the right one and explain what the other two would have selected instead.", + "files": [] + }, + { + "id": 7, + "prompt": "I have this base view:\n```likec4\nview backend-overview of cloud.backend {\n include *\n}\n```\nWrite a `detail` view that: (1) extends `backend-overview`, (2) adds just `include api` — relying on scope inheritance so I don't need the full FQN `cloud.backend.api`, and (3) adds all incoming relationships to `cloud.backend` from the rest of the model using a relationship predicate. Show the DSL and explain what scope inheritance means here.", + "files": [] + }, + { + "id": 8, + "prompt": "I want to define a reusable predicate group called `core-services` that includes all `service`-kind elements from `cloud.*` but excludes those tagged `#deprecated`. Use the exact reusable-predicate mechanism `global { predicateGroup ... }` and, in each view, apply it with the exact keyword form `global predicate core-services` (do not replace this with some other reusable-style mechanism). Then show two different views that both apply it: one that adds a per-view style rule on top, and another that adds an extra `include` rule.", + "files": [] + }, + { + "id": 9, + "prompt": "Write a dynamic view `checkout-flow` that satisfies all of the following:\n- The chain `customer -> frontend -> api` is written using LikeC4's chained step syntax (a single compound expression, not three separate lines).\n- The `frontend -> api` step has a body block with `technology 'HTTPS'` and `navigateTo payment-detail`.\n- After the chain, `api` fans out simultaneously to `payments`, `inventory`, and `notifications` using the parallel blocks syntax.\nKeep the snippet minimal and immediately usable.", + "files": [] + }, + { + "id": 10, + "prompt": "I want a minimal LikeC4 fixture that *proves* deployment-instance tags are cumulative for `source.tag` filtering. Assume logical `frontend` has tag `#next`, deployment instance `prod.eu.web.frontend` adds `#gamma`, and `db` is the target. Write one paste-ready `.c4` file containing: (1) minimal `specification`, `model`, and `deployment`; and (2) three `deployment view`s named `rel_only_next`, `rel_only_gamma`, and `rel_only_missing`, each using only a relationship include with `where source.tag is ...`. The first two views should render the `reads` relation; the third should render empty.", + "files": [] + }, + { + "id": 11, + "prompt": "My `base.c4` defines:\n```likec4\nmodel {\n cloud = system 'Cloud' {\n api = service 'API' {\n metadata { port '8080' }\n }\n }\n}\n```\nIn `ops.c4` I use `extend cloud.api` to add `metadata { port '9090'; region 'us-east-1' }` and a nested `health = component 'Health Check'`. Show the correct `ops.c4` snippet and state exactly what value `port` holds in the merged model, and why. Be explicit about whether the original `port '8080'` is overwritten or merged.", + "files": [] + }, + { + "id": 12, + "prompt": "In a `deployment view`, can I use `include * with { color red }` and `global style myTheme` to style nodes the same way as in element views? Answer in three parts: (1) say explicitly whether each construct is supported, (2) if you propose any local `style` rule as an alternative, make clear that it is a separate supported deployment-view construct rather than `with { ... }` or `global style`, and (3) provide a minimal corrected snippet that stays within supported deployment-view syntax.", + "files": [] + }, + { + "id": 13, + "prompt": "Fix this invalid LikeC4 snippet and explain why it is invalid:\n```likec4\nmodel {\n api = service 'API' {\n technology 'Node.js'\n #critical\n }\n}\n```\nKeep the corrected snippet minimal. Do not change the element kind or remove either field; only fix what is necessary.", + "files": [] + }, + { + "id": 14, + "prompt": "Someone proposed this file:\n```likec4\nstyles {\n theme dark\n}\n```\nExplain why this fails. Then provide (1) a minimal valid `.c4` alternative using only allowed top-level DSL statements for styling inside DSL scope, and (2) a minimal `likec4.config.json` snippet if the intent is a project-level dark theme.", + "files": [] + }, + { + "id": 15, + "prompt": "Which identifiers are valid in LikeC4 and why: `payment.api`, `1backend`, `payment_api`, `payment-api`? Then rewrite the invalid ones into valid identifiers while preserving meaning.", + "files": [] + }, + { + "id": 16, + "prompt": "Is this relationship valid in LikeC4? Start your answer with `Valid.` or `Invalid.` exactly.\n```likec4\nmodel {\n cloud = system 'Cloud' {\n backend = container 'Backend'\n cloud -> backend 'contains traffic'\n }\n}\n```\nIf invalid, explicitly say in one sentence that containment is modeled by nesting, not by a relationship. Then provide the smallest valid rewrite that keeps the hierarchy unchanged and adds exactly one valid non-parent/child relationship elsewhere.", + "files": [] + }, + { + "id": 17, + "prompt": "`base.c4` defines `cloud = system { backend = container { api = service } }`. In `ops.c4` I wrote `backend.api -> cloud.db` and validation says unresolved reference. Do not fix this with import statements. Explain exactly why this happens across files and provide the corrected cross-file relationship using FQNs.", + "files": [] + }, + { + "id": 18, + "prompt": "Assume these two relationships already exist: `frontend -[async]-> api 'streams'` and `frontend -> api 'streams'`. Can I extend the async one with `extend frontend -> api 'streams' { metadata { qos 'high' } }`? Explain why or why not in terms of relationship identity matching — source, target, title, and kind — and explicitly say whether omitting the kind is ambiguous or wrong here. Then provide the exact extension snippet that unambiguously targets the async relationship.", + "files": [] + }, + { + "id": 19, + "prompt": "In `view backend of cloud.backend { include * }`, what exactly does `*` include in scoped view semantics? Do not answer with only `the whole model` or only `the local subtree`. In your first bullet, you must explicitly use the phrase `direct children`. Answer in two short bullets: (1) the base include set, and (2) what neighboring or derived elements/relationships can still become visible because of that scoped include. Then provide a minimal snippet that keeps `include *` and adds only incoming and outgoing relationships around `cloud.backend`.", + "files": [] + }, + { + "id": 20, + "prompt": "I need to validate only two edited files in one command: `projects/template/system-model.c4` and `projects/template/system-views.c4`, without layout drift checks. Give the exact command and explain how to confirm both files were actually filtered in JSON output.", + "files": [] + }, + { + "id": 21, + "prompt": "Given this base view:\n```likec4\nview backend-overview of cloud.backend {\n include *\n}\n```\nIs the following child view valid as written, or does it need `of cloud.backend` again?\n```likec4\nview backend-detail extends backend-overview {\n include api\n}\n```\nAnswer first with `Valid as written.` or `Needs scope redeclared.` Then explain why in one short paragraph and show the smallest final snippet that also adds incoming relationships to `cloud.backend`.", + "files": [] + }, + { + "id": 22, + "prompt": "Write a minimal `dynamic view checkout-flow` snippet that simultaneously tests three tricky points: (1) `customer -> frontend -> api` must be a single chained expression, (2) only the `frontend -> api` hop carries a body with `technology 'HTTPS'` and `navigateTo payment-detail`, and (3) `api` fans out in `parallel { ... }` to `payments`, `inventory`, and `notifications`. After the snippet, add one sentence saying why rewriting the chain as separate standalone steps would not satisfy the request.", + "files": [] + }, + { + "id": 23, + "prompt": "A deployment instance `prod.eu.frontend` comes from `instanceOf frontend` and adds `#gamma`; the logical `frontend` element already has `#next`. For each filter below, answer on its own line in the form `: matches` or `: does not match`:\n- `where source.tag is #next`\n- `where source.tag is #gamma`\n- `where source.tag is #missing`\nThen add exactly one sentence stating the tag rule, including whether deployment-instance tags are cumulative, replacement-only, or isolated from the logical tags.", + "files": [] + }, + { + "id": 24, + "prompt": "Assume both of these relationships already exist: `frontend -[async]-> api 'streams'` and `frontend -[sync]-> api 'streams'`. Start your answer with exactly this line: `Correct matcher: extend frontend -[async]-> api 'streams'`. Then explain why `extend frontend -> api 'streams'` is insufficient here and why `extend frontend -[sync]-> api 'streams'` targets the wrong relationship. Then provide the exact extension snippet.", + "files": [] + }, + { + "id": 25, + "prompt": "I edited exactly three files in the template project: `projects/template/system-model.c4`, `projects/template/system-views.c4`, and `projects/template/likec4.config.json`. I want one syntax/semantic-only validation command from repo root, no layout drift. Put the command on the first line by itself, using repeated `--file` flags. Then say what `filteredFiles`, `filteredErrors`, and `totalErrors` each tell me. Also say what it means if `filteredFiles` comes back as `2` instead of `3`.", + "files": [] + }, + { + "id": 26, + "prompt": "I need the exact reusable-predicate form for LikeC4 — not a style group, not a custom include alias. Define `core-services` in a `global { predicateGroup ... }` block so it includes `cloud.*` elements whose kind is `service` and excludes `#deprecated`. Then show one minimal view that applies it with the exact line `global predicate core-services`. Keep the answer to one DSL snippet only.", + "files": [] + }, + { + "id": 27, + "prompt": "In a scoped view `view backend of cloud.backend { include * }`, choose exactly one statement and justify it in one sentence: (A) `include *` means the whole recursive subtree of `cloud.backend`, (B) `include *` means `cloud.backend` plus its direct children as the base include set, or (C) `include *` means the whole model. Start your answer with exactly `B` if that is the right choice. Then give one minimal snippet that keeps `include *` and adds only incoming relationships around `cloud.backend`.", + "files": [] + }, + { + "id": 28, + "prompt": "Assume these two relationships already exist: `frontend -[async]-> api 'streams'` and `frontend -> api 'streams'`. Start your answer with exactly `Wrong: extend frontend -> api 'streams'`. Then explain in one short paragraph why omitting the kind is wrong here, and finish with the exact `extend frontend -[async]-> api 'streams' { metadata { qos 'high' } }` snippet.", + "files": [] + }, + { + "id": 29, + "prompt": "Choose exactly one valid option and start your answer with only `A`, `B`, `C`, or `D` on the first line. Requirement: keep `customer -> frontend -> api` as one chained dynamic expression, attach a body only to the `frontend -> api` hop, then keep a parallel fan-out from `api`. Options: (A) `customer -> frontend -> api { technology 'HTTPS' navigateTo payment-detail }` then `parallel { api -> payments; api -> inventory; api -> notifications }`; (B) `customer -> frontend { technology 'HTTPS' } -> api` then `parallel { ... }`; (C) `customer -> frontend -> api` and separately `frontend -> api { technology 'HTTPS' navigateTo payment-detail }`; (D) `customer -> frontend; frontend -> api { technology 'HTTPS' navigateTo payment-detail }` then `parallel { ... }`. After the first-line letter, add one sentence only explaining why the others violate the chained-hop requirement.", + "files": [] + }, + { + "id": 30, + "prompt": "Assume logical `frontend` has tags `#next` and `#web`. Deployment has two instances: `prod.frontend = instanceOf frontend` with extra tag `#gamma`, and `canary.frontend = instanceOf frontend` with extra tag `#canary`. For each filter below, answer exactly in this format: ` => prod: canary:` on its own line. Filters: `where source.tag is #next`, `where source.tag is #web`, `where source.tag is #gamma`, `where source.tag is #canary`, `where source.tag is #missing`. Then add exactly one sentence that explicitly uses the word `cumulative` to state the deployment-instance tag rule.", + "files": [] + }, + { + "id": 31, + "prompt": "Assume existing relationships: `api -[async]-> service 'GetData'`, `api -[sync]-> service 'GetData'`, and `api -[async]-> service 'PostData'`. Classify each matcher as `correct`, `ambiguous`, or `wrong` (one line each): (1) `extend api -[async]-> service 'GetData' { metadata { timeout '5s' } }`; (2) `extend api -[async]-> service { metadata { timeout '5s' } }`; (3) `extend api -> service 'GetData' { metadata { timeout '5s' } }`. After classification lines, provide the single exact unambiguous extension snippet.", + "files": [] + }, + { + "id": 32, + "prompt": "Assume these relationships already exist: `client -> api 'call' { metadata { protocol 'https' } }` and `api -> db 'write' { metadata { protocol 'http' } }`. Show a minimal view that includes all relationships but excludes only relationships whose own metadata has `protocol` equal to `http`. Use `metadata.protocol`, not `source.metadata` or `target.metadata`.", + "files": [] + }, + { + "id": 33, + "prompt": "Assume `api` has `metadata { zone 'public' }`, `db` has `metadata { zone 'restricted' }`, and relationships already connect them. Show a minimal view that includes all relationships but excludes relationships whose target element metadata has `zone` equal to `restricted`. Use an endpoint metadata filter.", + "files": [] + }, + { + "id": 34, + "prompt": "In a deployment view, logical `api` has `metadata { zone 'model-public' }`; one deployed `api` instance has no metadata, and another deployed `api` instance has `metadata { zone 'instance-private' }`. For relationship filters using `where source.metadata.zone is ...`, state which metadata value each instance uses and give one sentence with the deployment metadata rule. Be explicit about fallback versus replacement.", + "files": [] + }, + { + "id": 35, + "prompt": "Show one minimal element view that keeps only services with array metadata `regions` containing `eu` and then removes any included element without an `owner` metadata key. Use `metadata.regions is \"eu\"` and `not metadata.owner` exactly.", + "files": [] + }, + { + "id": 36, + "prompt": "Write a minimal paste-ready `dynamic view auth-flow` snippet that models a conditional interaction. After `app -> api 'authorize'`, use LikeC4's flow-control `alt` block with two branches: `when 'authorized'` containing `api -> db 'load data'`, and `else 'denied'` containing `api -> app 'show login'`. Keep it snippet-only.", + "files": [] + }, + { + "id": 37, + "prompt": "Show a minimal paste-ready `dynamic view query-flow` snippet that wraps `service -> db 'query'` in a `try` block, adds a `catch 'on error'` branch with `service -> logger 'log failure'`, and a `finally` branch with `service -> service 'release connection'`. Keep the try/catch/finally order correct and output the snippet only.", + "files": [] + }, + { + "id": 38, + "prompt": "Start your answer with exactly `Invalid.` or `Valid.` on the first line. Is this dynamic view valid?\n```likec4\ndynamic view flow {\n alt {\n loop {\n a -> b\n }\n }\n}\n```\nExplain in one sentence why, then give the corrected minimal snippet that keeps the `loop` but satisfies the `alt`-branch rule.", + "files": [] + }, + { + "id": 39, + "prompt": "The model has `cloud.ui` (title \"Web UI\") and `cloud.api`, and a view `index`. Give me a minimal paste-ready `description` property for `cloud.api` that (1) links to `cloud.ui` in a sentence so the element title is shown, (2) links to the view `index`, and (3) has a Markdown table with one row whose first cell links to `cloud.ui` with the label `Frontend`. Snippet first, then one sentence on what happens if a linked FQN does not exist.", + "files": [] + } + ] +} diff --git a/.agents/skills/likec4-dsl/evals/grading-spec.json b/.agents/skills/likec4-dsl/evals/grading-spec.json new file mode 100644 index 0000000..af2ccf5 --- /dev/null +++ b/.agents/skills/likec4-dsl/evals/grading-spec.json @@ -0,0 +1,603 @@ +{ + "skill_name": "likec4-dsl", + "evals": [ + { + "id": 0, + "expected_output": "The response gives the focused `likec4 validate` command with `--json`, `--no-layout`, `--file`, and the correct project path, then explains how to interpret `filteredFiles` and `filteredErrors` versus `totalErrors`.", + "files": [], + "expectations": [ + "The response uses `npx likec4 validate --json --no-layout --file projects/template/system-model.c4 projects/template` or an equivalent command with the same flags and project path; version pins such as `npx likec4@latest validate` are also accepted; platform-specific redirects like `2>/dev/null` are optional.", + "The response explains that `--no-layout` skips layout drift checks.", + "The response explains that `--file` scopes reported errors to the edited file.", + "The response mentions `filteredFiles` and `filteredErrors` as the key fields for the targeted file check.", + "The response distinguishes those filtered fields from `totalErrors` or other project-wide counts." + ], + "grading_guidance": "Accept any reasonable version pin in the command (`npx likec4@latest`, `npx likec4@X.Y.Z`, or bare `npx likec4`). Never require `2>/dev/null` or other platform-specific redirections. The semantic invariants — `--json`, `--no-layout`, `--file `, correct project path, and accurate interpretation of `filteredFiles`/`filteredErrors`/`totalErrors` — must all be present for full credit." + }, + { + "id": 1, + "expected_output": "A minimal JSON config with `$schema`, `name`, and `include.paths` for `../shared`, plus a correct explanation that the nearest config file in the directory hierarchy determines project scope.", + "files": [], + "expectations": [ + "The response includes a JSON config snippet, not just prose.", + "The snippet includes `$schema` pointing to the LikeC4 config schema.", + "The snippet includes a valid `name` field.", + "The snippet uses `include.paths` with `../shared` or an equivalent relative path to the shared folder.", + "The explanation says that `.c4` files belong to the project defined by the nearest config file in the directory hierarchy." + ] + }, + { + "id": 2, + "expected_output": "The response gives the `export png` CLI command with dark theme, flat output, a filter for `overview*`, an output directory, and the project path placeholder or path.", + "files": [], + "expectations": [ + "The response uses `npx likec4 export png`.", + "The command includes `--theme dark`.", + "The command includes `--flat`.", + "The command includes a filter option equivalent to `-f \"overview*\"`.", + "The command includes an output directory option equivalent to `-o ./images` and keeps a project path argument." + ] + }, + { + "id": 3, + "expected_output": "A paste-ready `dynamic view` snippet that uses `variant sequence`, forward steps from `client` to `db`, and backward response arrows back toward `client`.", + "files": [], + "expectations": [ + "The response uses a `dynamic view` block.", + "The snippet includes `variant sequence`.", + "The snippet models the forward flow across `client`, `gateway`, `orders`, and `db`.", + "The snippet includes at least one backward arrow using `<-` for responses.", + "The response is primarily a usable DSL snippet, not only an explanation." + ] + }, + { + "id": 4, + "expected_output": "The response keeps the styling in the view, not the model or specification, and provides a minimal view snippet that mutes everything then highlights `cloud.backend.*`.", + "files": [], + "expectations": [ + "The response explicitly says the styling should live in the view rather than in the model or specification for this one-view customization.", + "The response uses a `view` block.", + "The snippet includes `include *` or an equivalent rule that gives the view context.", + "The snippet includes a style rule that mutes the broader set, such as `style * { color muted }`.", + "The snippet includes a style rule that highlights `cloud.backend.*`." + ] + }, + { + "id": 5, + "expected_output": "A minimal `deployment` snippet with one VM node that contains two separately named `instanceOf cloud.api` declarations, not two anonymous `instanceOf` lines.", + "files": [], + "expectations": [ + "The response uses a `deployment` block.", + "The snippet includes a `vm` deployment node.", + "The snippet creates two separately named instances inside the same node.", + "Each named instance uses `instanceOf cloud.api`.", + "The response does not treat two anonymous `instanceOf cloud.api` lines as satisfying the request." + ], + "grading_guidance": "This eval is primarily about named deployment instances inside one node. If the answer uses anonymous instances or leaves the naming ambiguity unresolved, it should lose clearly even if the surrounding deployment block is otherwise valid. Do not over-weight extra explanation once the naming requirement is either satisfied or violated. Note: `instanceOf` is a DSL keyword, not a `deploymentNode` kind — the structural snippet checker may incorrectly flag `instanceOf` as an 'Unknown LikeC4 kind'; this is a checker limitation and should not count as a content error." + }, + { + "id": 6, + "expected_output": "The response selects `cloud.backend._` (underscore) as the correct predicate, provides a minimal scoped view snippet using it, and explains that `*` selects all direct children unconditionally and `**` selects all recursive descendants that have relationships.", + "files": [], + "expectations": [ + "The response identifies `cloud.backend._` (underscore) as the correct predicate for children with relationships to accumulated result.", + "The response explains that `cloud.backend.*` selects ALL direct children regardless of whether they have relationships.", + "The response explains that `cloud.backend.**` selects all recursive descendants (not just direct children) that have relationships.", + "The response provides a minimal view snippet using `cloud.backend._` inside a scoped `view ... of cloud.backend { ... }` block.", + "The response does not confuse `_` with a wildcard or treat it as selecting no elements." + ] + }, + { + "id": 7, + "expected_output": "A `detail` view using `extends backend-overview`, with a bare `include api` (not full FQN) that resolves to `cloud.backend.api` due to inherited scope, and a relationship predicate for incoming relationships such as `include -> cloud.backend`.", + "files": [], + "expectations": [ + "The response uses `view detail extends backend-overview { ... }` syntax.", + "The snippet uses `include api` without the full FQN `cloud.backend.api`, relying on scope inheritance.", + "The response explains that extended views inherit the `of` scope from the parent view.", + "The snippet includes an inbound relationship predicate such as `include -> cloud.backend` or `include -> cloud.backend ->` to pull in incoming relationships.", + "The response does not require the user to redeclare the `of cloud.backend` scope on the child view." + ] + }, + { + "id": 8, + "expected_output": "A `global { predicateGroup core-services { ... } }` block with include/exclude rules for service kind and #deprecated tag, then two views using `global predicate core-services` each with a distinct additional rule.", + "files": [], + "expectations": [ + "The response uses a `global { predicateGroup core-services { ... } }` block at the top level.", + "The predicate group contains `include cloud.* where kind is service` (or equivalent).", + "The predicate group excludes deprecated elements using `exclude * where tag is #deprecated` or a combined `and tag is not #deprecated` filter.", + "Both views use `global predicate core-services` to apply the shared group.", + "Each view demonstrates an additional distinct rule: one adds a `style` rule and the other adds an `include` rule." + ], + "grading_guidance": "This is a contrastive syntax eval. The exact reusable-predicate mechanism matters: prefer answers that literally use `global { predicateGroup ... }` and `global predicate core-services`, and penalize substitutions such as style groups, prose-only explanations, or other reusable constructs even if they look conceptually adjacent. Accept `include -> cloud.* ->` as valid — it is the `-> expr ->` predicate that means 'relationships between elements matching expr and the accumulated result'. The structural snippet checker may flag `*` after `->` as an 'Unknown relationship kind'; this is a checker false positive. Grade on semantic correctness of the predicate, not on structural-checker output." + }, + { + "id": 9, + "expected_output": "A `dynamic view checkout-flow` using chained arrow syntax for the three-step chain, a body block on the `frontend -> api` step with `technology` and `navigateTo`, and a `parallel { ... }` block for the fan-out from `api`.", + "files": [], + "expectations": [ + "The response uses a `dynamic view checkout-flow { ... }` block.", + "The chain `customer -> frontend -> api` is written as a single chained expression (arrows on the same or continuation lines), not as three separate standalone `->` lines.", + "The `frontend -> api` step includes a body block `{ technology 'HTTPS' navigateTo payment-detail }` (or equivalent).", + "The snippet includes a `parallel { ... }` (or `par { ... }`) block containing three concurrent steps: `api -> payments`, `api -> inventory`, `api -> notifications`.", + "The response is primarily a usable DSL snippet, not only explanation." + ] + }, + { + "id": 10, + "expected_output": "The response provides one minimal paste-ready `.c4` fixture that proves cumulative deployment-instance tag filtering: logical `frontend` has `#next`, deployed `prod.eu.web.frontend` adds `#gamma`, the logical relation is `frontend -> db 'reads'`, and the three deployment views `rel_only_next`, `rel_only_gamma`, and `rel_only_missing` use relationship-only `include ... where source.tag is ...` predicates such that the first two can render the relation and the third is empty.", + "files": [], + "expectations": [ + "The response includes a valid top-level `specification`, `model`, `deployment`, and `views` structure in one `.c4` snippet.", + "The logical model tags `frontend` with `#next` and defines a logical `frontend -> db 'reads'` relationship.", + "The deployment model creates a deployed `frontend` instance under `prod.eu.web.frontend` (or equivalent nested FQN) and adds `#gamma` on the `instanceOf` block.", + "The snippet defines exactly the three deployment views `rel_only_next`, `rel_only_gamma`, and `rel_only_missing`.", + "Each of those views uses a relationship-only include with `where source.tag is #next`, `#gamma`, and `#missing` respectively, with the intended semantic outcome that the first two match and the last one renders empty." + ], + "grading_guidance": "This eval replaces the old duplicate knowledge question with an executable fixture-building task. Prefer concise snippets that make the cumulative-tag rule mechanically testable; do not require extra prose if the DSL is clear and consistent.", + "execution_checks": [ + { + "name": "Validate the full LikeC4 fixture when present", + "description": "Use the LikeC4 validator as extra evidence when the response contains a single paste-ready `.c4` fixture rather than only prose fragments.", + "when": "Run this when the answer includes one main LikeC4 code block that appears intended to be the requested complete file.", + "instructions": [ + "Extract the main LikeC4 code block from the response.", + "Create a temporary LikeC4 project directory in the workspace and, if needed, add the minimal config required for isolated validation.", + "Save the extracted snippet as a `.c4` file and validate it with the LikeC4 CLI using JSON output and no layout checks.", + "Treat validator errors as strong negative evidence against claims that the fixture is paste-ready or semantically correct." + ], + "success_signals": [ + "The validator reports zero syntax or semantic errors for the extracted file.", + "The validated file still exhibits the intended cumulative-tag behavior described by the expectations." + ], + "failure_signals": [ + "The validator reports parse, semantic, or unresolved-reference errors.", + "The file structure prevents the three requested deployment views from expressing the intended tag-filter outcomes." + ] + } + ] + }, + { + "id": 11, + "expected_output": "The correct `ops.c4` snippet uses `extend cloud.api { metadata { port '9090'; region 'us-east-1' } health = component 'Health Check' }`, and the response states that `port` becomes the array `['8080', '9090']` because duplicate metadata keys are merged into arrays instead of being overwritten.", + "files": [], + "expectations": [ + "The response shows a correct `extend cloud.api { ... }` block (not a re-declaration of `cloud.api`).", + "The `extend` block contains `metadata { port '9090'; region 'us-east-1' }` inside it.", + "The `extend` block also declares the nested `health = component 'Health Check'`.", + "The response explicitly states that `port` ends up as an array `['8080', '9090']` (or equivalent notation).", + "The response explains WHY: duplicate metadata keys are merged into arrays rather than overwriting." + ], + "grading_guidance": "Treat any answer claiming that `port` is overwritten to a single scalar value as incorrect, even if the extend block itself is otherwise valid." + }, + { + "id": 12, + "expected_output": "The response states that deployment views do not support customize predicates (`with { ... }`) and do not support `global style ...`, and if it proposes local styling it clearly presents that as a separate supported deployment-view construct rather than as `with { ... }` or `global style`. It then provides a minimal valid deployment view snippet using only supported syntax.", + "files": [], + "expectations": [ + "The response explicitly says `include * with { ... }` is not supported in deployment views.", + "The response explicitly says `global style ...` is not supported in deployment views.", + "The response still provides a valid `deployment view` snippet.", + "The snippet uses only supported deployment-view rules.", + "If the answer uses a local `style` rule, it clearly distinguishes that supported local rule from unsupported `with { ... }` and unsupported `global style ...`.", + "The response distinguishes deployment-view limitations from element-view capabilities." + ], + "grading_guidance": "Do not penalize an answer merely for using a valid local deployment-view `style` rule such as `style * { color red }`, as long as it explicitly says this is a separate supported construct and does not falsely present `include * with { ... }` or `global style ...` as supported. The main failure cases are recommending unsupported syntax or overstating deployment-view parity with element views.", + "execution_checks": [ + { + "name": "Validate the corrected deployment-view snippet when feasible", + "description": "When the response gives a concrete deployment-view snippet, use a tiny temporary harness to verify that the contested constructs are treated correctly by the validator.", + "when": "Run this when the answer includes a concrete deployment-view snippet that can be wrapped in a minimal LikeC4 file without guessing away the core syntax being evaluated.", + "instructions": [ + "Extract the deployment-view snippet from the response.", + "Wrap it in a minimal temporary LikeC4 project containing just enough specification, model, deployment, and views structure for validation.", + "Validate the wrapped file with the LikeC4 CLI using JSON output and no layout checks.", + "If the proposed snippet still relies on `with { ... }` or `global style ...`, treat the resulting validator failure as decisive evidence against correctness." + ], + "success_signals": [ + "The wrapped snippet validates successfully.", + "The snippet avoids unsupported `with { ... }` and `global style ...` deployment-view syntax." + ], + "failure_signals": [ + "Validation fails because the answer preserved unsupported deployment-view styling syntax.", + "The answer claims deployment-view support that the validated snippet does not demonstrate." + ] + } + ] + }, + { + "id": 13, + "expected_output": "The response fixes ordering in the element body by placing tags before properties, then explains that tags must precede properties in block bodies.", + "files": [], + "expectations": [ + "The corrected snippet keeps the same element intent (`api = service 'API'`).", + "The corrected snippet places `#critical` before `technology 'Node.js'`.", + "The response explicitly explains the ordering rule: tags come before properties.", + "The response does not remove the tag or technology field as a workaround.", + "The corrected snippet remains minimal and syntactically valid LikeC4 DSL." + ] + }, + { + "id": 14, + "expected_output": "The response says `styles { ... }` is not an allowed top-level block and replaces it with valid top-level statements, typically using `global { styleGroup ... }` and/or `views { ... }`.", + "files": [], + "expectations": [ + "The response identifies that `styles` is an invalid top-level statement.", + "The response mentions allowed top-level statements (`import`, `specification`, `model`, `deployment`, `views`, `global`, `diff`).", + "The corrected file uses only allowed top-level statements.", + "The corrected DSL demonstrates styling intent via valid constructs (e.g., `global styleGroup` and applying in `views`).", + "The output is a minimal valid LikeC4 file, not only prose." + ] + }, + { + "id": 15, + "expected_output": "The response marks `payment.api` and `1backend` invalid, marks `payment_api` and `payment-api` valid, and rewrites invalid identifiers into valid forms without dots or leading digits.", + "files": [], + "expectations": [ + "The response states `payment.api` is invalid because dots are reserved for FQNs.", + "The response states `1backend` is invalid because identifiers cannot start with a digit.", + "The response states `payment_api` is valid.", + "The response states `payment-api` is valid.", + "The response provides corrected alternatives for invalid identifiers preserving meaning (e.g., `payment_api`, `backend1`)." + ] + }, + { + "id": 16, + "expected_output": "The response says parent-child relationships are invalid, removes `cloud -> backend` relationship, and provides a corrected pattern that preserves hierarchy and adds a valid relationship between non-parent/child elements.", + "files": [], + "expectations": [ + "The response starts with `Invalid.` exactly.", + "The response explicitly identifies `cloud -> backend` as invalid parent-child relationship.", + "The corrected snippet preserves the hierarchical nesting of `backend` inside `cloud`.", + "The corrected snippet demonstrates a valid relationship between non-parent-child elements.", + "The response does not claim the original relation is valid with a different label.", + "The corrected output is a minimal, usable DSL snippet." + ], + "grading_guidance": "Treat the exact opening verdict `Invalid.` and the explicit sentence that containment is modeled by nesting, not by a relationship, as primary signals. Answers that hedge on validity, skip the exact verdict, or preserve a parent-child relationship should lose clearly even if the replacement snippet is otherwise plausible." + }, + { + "id": 17, + "expected_output": "The response explains that cross-file references to nested elements require FQNs and rewrites the relationship using full element paths.", + "files": [], + "expectations": [ + "The response explains why `backend.api` fails across files (lexical scope and unresolved local reference).", + "The response states that cross-file nested references must use FQNs.", + "The corrected relationship uses full FQNs (e.g., `cloud.backend.api -> cloud.db`).", + "The response does not suggest importing symbols as a substitute for FQNs in this case.", + "The answer remains focused on reference resolution and corrected DSL." + ], + "grading_guidance": "Answers that name FQNs in prose but provide a code snippet that still uses unresolved local references (e.g., `backend.api`) should receive at most partial credit. The corrected snippet itself must contain the correct full FQN form. Reject answers that suggest `import` statements as an alternative fix; partial fixes that avoid naming both sides as FQNs also receive reduced credit." + }, + { + "id": 18, + "expected_output": "The response explains that relationship extension must match source, target, title, and kind; it rejects `extend frontend -> api 'streams'` for the async relationship here because omitting the kind is wrong for this disambiguation case, and provides the correct `extend frontend -[async]-> api 'streams' { ... }` snippet.", + "files": [], + "expectations": [ + "The response states that `extend` for relationships must match source and target.", + "The response states that matching also includes title and kind for disambiguation.", + "The response identifies `extend frontend -> api 'streams'` as incorrect for a relation originally declared with kind `async`.", + "The corrected snippet includes the relationship kind in the `extend` matcher.", + "The corrected snippet adds metadata within the `extend` block." + ], + "grading_guidance": "Do not reward answers that hand-wave the missing kind as 'probably okay'. In this eval, the whole point is exact relationship identity matching under a real ambiguity. Prefer answers that explicitly say omitting the kind is wrong here, not merely ambiguous in the abstract." + }, + { + "id": 19, + "expected_output": "The response explains that scoped `include *` does not include the whole model: it includes the scoped element and its direct children as the base include, and neighboring elements / derived relationships may also become visible because of those included elements under scoped-view semantics. It then shows predicates for incoming/outgoing relationships around `cloud.backend`.", + "files": [], + "expectations": [ + "The response explicitly says scoped `*` does NOT include the whole model.", + "The response explains that in a scoped view the base include is the scoped element plus its direct children.", + "The response explains that neighboring elements and/or derived relationships can also appear because of the scoped include semantics.", + "The snippet uses `view ... of cloud.backend` with `include *`.", + "The snippet adds relationship predicates for incoming/outgoing around `cloud.backend` (e.g., `include -> cloud.backend ->`).", + "The output includes a minimal usable DSL snippet, not prose-only explanation." + ], + "grading_guidance": "Do not over-credit answers that reduce scoped `include *` to only the local subtree, and do not accept `the whole model` either. The key concept is: scoped element plus direct children (NOT all recursive descendants, NOT the entire model) as the base include, with neighboring or derived visibility emerging from those included nodes under scoped semantics. Prefer answers that explicitly use the phrase `direct children`. Penalize any answer that says `*` includes elements deeper than direct children, or that uses 'subtree' without qualifying it as 'direct children only'. The snippet relationship predicates for incoming/outgoing around `cloud.backend` are also required; accept both `include -> cloud.backend ->` and the split form `include * -> cloud.backend` + `include cloud.backend -> *`." + }, + { + "id": 20, + "expected_output": "The response provides a `likec4 validate` command with `--json`, `--no-layout`, and repeated `--file` for both edited files, then explains checking `filteredFiles` and `filteredErrors` to confirm filter scope.", + "files": [], + "expectations": [ + "The command uses `npx likec4 validate --json --no-layout`.", + "The command passes two `--file` arguments, one for each edited file.", + "The command includes the project path argument (e.g., `projects/template`).", + "The response explains that `filteredFiles` should equal 2 to confirm both files were filtered.", + "The response explains interpreting `filteredErrors` versus project-wide counts like `totalErrors`." + ], + "grading_guidance": "Accept any reasonable version pin (`npx likec4@latest`, version-pinned, or bare `npx likec4`). The two `--file` flags, correct project path, and the `filteredFiles = 2` verification explanation are the strict semantic invariants; argument order is flexible." + }, + { + "id": 21, + "expected_output": "The response starts with `Valid as written.`, explains that `extends backend-overview` inherits the parent `of cloud.backend` scope so `include api` resolves within that scope, and shows a minimal final snippet that also pulls incoming relationships to `cloud.backend`.", + "files": [], + "expectations": [ + "The response starts with `Valid as written.`.", + "The response explains that the child view inherits the parent `of cloud.backend` scope.", + "The final snippet keeps `extends backend-overview` without redeclaring `of cloud.backend`.", + "The final snippet uses bare `include api` rather than `include cloud.backend.api`.", + "The final snippet adds an incoming relationship predicate for `cloud.backend`." + ], + "grading_guidance": "This eval is contrastive: answers that redeclare the scope or claim redeclaration is required should lose clearly, even if the rest of the snippet is plausible. Items 1–2 (binary verdict `Valid as written.` and scope-inheritance explanation) are the primary grading signals and account for roughly 60% of the score. Missing the exact opening phrase should noticeably reduce score even if the later explanation is correct. Items 3–5 (snippet correctness) are secondary completeness checks. Eval 21 is a validation task; eval 7 is the complementary construction task — assess each against its own primary objective." + }, + { + "id": 22, + "expected_output": "A minimal `dynamic view checkout-flow` snippet with `customer -> frontend -> api` as one chained expression, a body only on the `frontend -> api` hop containing `technology 'HTTPS'` and `navigateTo payment-detail`, a `parallel { ... }` fan-out from `api`, plus one sentence explaining that separate standalone steps would not preserve the requested chained-step structure.", + "files": [], + "expectations": [ + "The response uses a `dynamic view checkout-flow { ... }` block.", + "The `customer -> frontend -> api` portion is written as a single chained expression (possibly split across continuation lines) rather than three standalone top-level steps.", + "Only the `frontend -> api` hop carries the body block with `technology 'HTTPS'` and `navigateTo payment-detail`.", + "The snippet includes a `parallel { ... }` (or `par { ... }`) fan-out from `api` to `payments`, `inventory`, and `notifications`.", + "The explanation sentence says why separate standalone steps would not satisfy the requested chained-step form." + ], + "grading_guidance": "Penalize answers that flatten the chain into standalone top-level steps. Multi-line chain continuations (a single compound expression split across lines) are accepted. Both `par { }` and `parallel { }` are syntactically equivalent and both accepted. This eval is about exact dynamic-step structure, not topology, snippet length, or surface formatting. Note: the structural snippet checker may flag relationship step labels (e.g., `'charge'`, `'reserve'`) as 'Unknown relationship kind'; this is a checker false positive since step labels are valid in dynamic view syntax. Do not penalize a response solely due to structural-checker errors that arise from step label parsing." + }, + { + "id": 23, + "expected_output": "The response says `#next` matches, `#gamma` matches, `#missing` does not match, and states in one clear sentence that deployment-instance tags are cumulative: inherited logical-model tags remain visible and directly added instance tags are added on top.", + "files": [], + "expectations": [ + "The response says the `#next` filter matches.", + "The response says the `#gamma` filter matches.", + "The response says the `#missing` filter does not match.", + "The response explains that logical-model tags remain visible on the deployment instance.", + "The response explicitly states that deployment-instance tags are cumulative rather than replacement-only or isolated." + ], + "grading_guidance": "Grade this mechanically. The three filter verdicts and the cumulative-tag rule are the primary signals; do not over-weight presentation flourish once those concrete semantics are correct. Missing the `#missing` negative case or avoiding the cumulative-vs-replace distinction should still reduce confidence noticeably." + }, + { + "id": 24, + "expected_output": "The response identifies `extend frontend -[async]-> api 'streams'` as the correct matcher, explains that the unkinded matcher is insufficient here and that the sync matcher targets the wrong relationship, then provides the exact async extension snippet.", + "files": [], + "expectations": [ + "The response identifies `extend frontend -[async]-> api 'streams'` as the correct matcher.", + "The response explains that `extend frontend -> api 'streams'` is not the right matcher in this ambiguity case.", + "The response explains that `extend frontend -[sync]-> api 'streams'` would target the wrong relationship.", + "The final snippet uses the async matcher exactly.", + "The final snippet adds metadata within the `extend` block." + ], + "grading_guidance": "Treat exact matcher selection as the primary grading signal. Prefer answers that begin with the exact required matcher line when the prompt asks for it. Answers that fail to explicitly reject `extend frontend -> api 'streams'` as ambiguous or insufficient in this context lose points, even if they eventually provide the correct async matcher. Do not let extra rhetorical explanation outweigh a wrong matcher choice." + }, + { + "id": 25, + "expected_output": "The response provides a `likec4 validate` command with `--json`, `--no-layout`, and three repeated `--file` flags for the edited template files, explains the roles of `filteredFiles`, `filteredErrors`, and `totalErrors`, and states that `filteredFiles = 2` means one intended file was not actually included in the filter set.", + "files": [], + "expectations": [ + "The command uses `npx likec4 validate --json --no-layout`.", + "The command passes three `--file` arguments, one per edited file.", + "The command includes the template project path argument.", + "The response explains `filteredFiles`, `filteredErrors`, and `totalErrors` distinctly.", + "The response explains that `filteredFiles = 2` means one of the intended files was not actually filtered by the command." + ], + "grading_guidance": "Do not give full credit to answers that only restate the command. This eval checks partial filter coverage interpretation: `filteredFiles = 2` means one of the three intended files was not captured, and the response must state this explicitly. Prefer answers that put the command on the first line by itself when the prompt requires it. Accept any reasonable version pin (`npx likec4@latest`, `npx likec4@X.Y.Z`, or bare `npx likec4`). The semantic invariants — three `--file` flags, correct project path, accurate distinction of all three JSON fields, and the `filteredFiles = 2` interpretation — are strict and non-negotiable." + }, + { + "id": 26, + "expected_output": "A single DSL snippet that defines `core-services` using `global { predicateGroup ... }` and then applies it in one view with the exact line `global predicate core-services`.", + "files": [], + "expectations": [ + "The response contains one DSL snippet only.", + "The snippet uses `global { predicateGroup core-services { ... } }` exactly as the reusable mechanism.", + "The predicate group includes `cloud.*` elements whose kind is `service`.", + "The predicate group excludes elements tagged `#deprecated`.", + "The view applies the reusable predicate with the exact line `global predicate core-services`." + ], + "grading_guidance": "This is a sharp contrastive eval. Do not give full credit to answers that substitute style groups, aliases, prose descriptions, or any other reusable mechanism. Exact keyword fidelity (`predicateGroup`, `global predicate`) is the primary signal." + }, + { + "id": 27, + "expected_output": "The response starts with `B`, explicitly identifies scoped `include *` as the scoped element plus its direct children as the base include set, and gives a minimal snippet that keeps `include *` while adding incoming relationships around `cloud.backend`.", + "files": [], + "expectations": [ + "The response starts with `B` exactly.", + "The justification states that the base include set is the scoped element plus its direct children.", + "The answer rejects both the recursive-subtree interpretation and the whole-model interpretation.", + "The snippet keeps `include *` inside `view ... of cloud.backend { ... }`.", + "The snippet adds only incoming relationships around `cloud.backend`." + ], + "grading_guidance": "Treat the exact initial choice `B` and the phrase `direct children` as primary signals. Reject answers that blur choice B with a full subtree interpretation even if the rest of the prose sounds knowledgeable." + }, + { + "id": 28, + "expected_output": "The response starts with `Wrong: extend frontend -> api 'streams'`, explains that omitting the relationship kind is wrong under this ambiguity, and ends with the exact async extension snippet including metadata.", + "files": [], + "expectations": [ + "The response starts with `Wrong: extend frontend -> api 'streams'` exactly.", + "The explanation says omitting the kind is wrong here because multiple relationships could match.", + "The explanation stays focused on relationship identity matching rather than unrelated syntax issues.", + "The final snippet is exactly `extend frontend -[async]-> api 'streams' { metadata { qos 'high' } }` or a formatting-equivalent multi-line form.", + "The answer does not present the unkinded matcher as acceptable." + ], + "grading_guidance": "This eval is about explicit rejection of the near-miss matcher. The exact opening line and the exact async extension snippet are the decisive signals; do not over-credit generic ambiguity talk if those are missing." + }, + { + "id": 29, + "expected_output": "The response selects only option `A`, places only `A`/`B`/`C`/`D` on the first line, and gives one sentence explaining that the others break chained-hop body semantics.", + "files": [], + "expectations": [ + "The first line is exactly one letter among `A`, `B`, `C`, or `D`.", + "The selected option is `A`.", + "The explanation is exactly one sentence after the first line.", + "The explanation mentions why non-`A` options violate chained-hop/body placement constraints.", + "The response does not include extra snippets or alternative rewrites." + ], + "grading_guidance": "This is a strict-format discriminator. The first-line token and option choice dominate scoring. Any first-line mismatch or selecting non-`A` should lose heavily, even if surrounding prose is generally knowledgeable." + }, + { + "id": 30, + "expected_output": "The response outputs five required lines in the exact ` => prod: canary:` format with correct match matrix (`#next` and `#web` match both, `#gamma` only prod, `#canary` only canary, `#missing` neither), followed by exactly one sentence explicitly using the word `cumulative`.", + "files": [], + "expectations": [ + "The response includes one line for each required filter: `#next`, `#web`, `#gamma`, `#canary`, `#missing`.", + "`#next` line marks `prod:match` and `canary:match`.", + "`#web` line marks `prod:match` and `canary:match`.", + "`#gamma` line marks `prod:match` and `canary:no`, and `#canary` line marks `prod:no` and `canary:match`.", + "`#missing` line marks `prod:no` and `canary:no`.", + "The final rule sentence contains the word `cumulative` and states the inherited-logical-plus-instance-tag behavior." + ], + "grading_guidance": "Grade mechanically by the matrix and format contract. Missing one filter line, swapping prod/canary outcomes, or omitting the word `cumulative` in the final sentence should materially reduce score." + }, + { + "id": 31, + "expected_output": "The response classifies (1) as `correct`, (2) as `ambiguous`, and (3) as `wrong`, then provides the exact unambiguous extension snippet `extend api -[async]-> service 'GetData' { metadata { timeout '5s' } }` (format-equivalent multi-line accepted).", + "files": [], + "expectations": [ + "Classification line for matcher (1) marks it `correct`.", + "Classification line for matcher (2) marks it `ambiguous` because title is omitted while multiple async relationships exist.", + "Classification line for matcher (3) marks it `wrong` because kind is omitted while typed alternatives exist.", + "The final snippet targets async + exact title `GetData`.", + "The final snippet includes metadata field `timeout '5s'`." + ], + "grading_guidance": "Prioritize matcher-identity reasoning (source/target/kind/title). Answers that call (2) fully correct or call (3) acceptable should lose clearly, even if they provide a plausible final snippet." + }, + { + "id": 32, + "expected_output": "The response provides a minimal view with `include * -> *` followed by `exclude * -> * where metadata.protocol is \"http\"` (quote style may vary), and it treats `metadata.protocol` as relationship-own metadata rather than endpoint metadata.", + "files": [], + "expectations": [ + "The snippet includes all relationships with a relationship predicate such as `include * -> *`.", + "The exclusion uses `exclude * -> * where metadata.protocol is \"http\"` or an equivalent multi-line form.", + "The response does not use `source.metadata.protocol` or `target.metadata.protocol` for this own-relationship metadata case.", + "The response is primarily a usable LikeC4 view snippet." + ], + "grading_guidance": "The distinction between relationship metadata and endpoint metadata is the main signal. Do not give full credit if the answer filters `source.metadata` or `target.metadata` when the prompt explicitly asks for the relationship's own metadata." + }, + { + "id": 33, + "expected_output": "The response provides a minimal view with `include * -> *` followed by `exclude * -> * where target.metadata.zone is \"restricted\"` (quote style may vary), using endpoint metadata on the target.", + "files": [], + "expectations": [ + "The snippet includes relationships before excluding from them.", + "The exclusion uses `target.metadata.zone is \"restricted\"` or an equivalent multi-line form.", + "The response uses endpoint metadata rather than relationship-own `metadata.zone`.", + "The response is primarily a usable LikeC4 view snippet." + ], + "grading_guidance": "The target endpoint qualifier is required. Answers that use unqualified `metadata.zone` are filtering the relationship itself and should lose clearly." + }, + { + "id": 34, + "expected_output": "The response states that the deployed instance without metadata falls back to logical metadata value `model-public`, while the instance with metadata uses `instance-private` and replaces the logical metadata for predicate filtering. It includes one clear rule sentence distinguishing fallback from replacement.", + "files": [], + "expectations": [ + "The no-instance-metadata case uses `model-public`.", + "The instance-metadata case uses `instance-private`.", + "The response explicitly says instance metadata replaces logical element metadata when present.", + "The response explicitly says logical element metadata is used as fallback when the deployed instance has no metadata.", + "The rule is framed for deployment-view relationship endpoint filters such as `source.metadata.zone`." + ], + "grading_guidance": "Do not accept answers that claim deployment metadata merges key-by-key with logical metadata. The key contrast is fallback when absent versus replacement when present." + }, + { + "id": 35, + "expected_output": "The response provides one minimal element view using `include * where kind is service and metadata.regions is \"eu\"` (or equivalent ordering) and `exclude * where not metadata.owner`.", + "files": [], + "expectations": [ + "The view includes services using `kind is service`.", + "The include filter uses `metadata.regions is \"eu\"` to express array containment.", + "The view removes elements missing owner metadata with `exclude * where not metadata.owner`.", + "The response provides one minimal view snippet rather than prose-only guidance." + ], + "grading_guidance": "The eval checks both array containment syntax and missing-key syntax. Accept equivalent line breaks and quote style, but not invented operators such as `contains` or `exists`." + }, + { + "id": 36, + "expected_output": "A paste-ready `dynamic view auth-flow` snippet with the forward step `app -> api 'authorize'`, then an `alt { ... }` flow-control block containing a `when 'authorized'` branch (`api -> db 'load data'`) and an `else 'denied'` branch (`api -> app 'show login'`).", + "files": [], + "expectations": [ + "The response uses a `dynamic view auth-flow { ... }` block.", + "The snippet includes the `app -> api 'authorize'` step before the block.", + "The snippet uses an `alt { ... }` block.", + "The `alt` block contains a `when 'authorized'` branch whose body is `api -> db 'load data'`.", + "The `alt` block contains an `else 'denied'` branch whose body is `api -> app 'show login'`.", + "The `when` / `else` branches appear only inside the `alt` block, not as standalone top-level steps.", + "The response is primarily a usable DSL snippet, not only an explanation." + ], + "grading_guidance": "Flow-control blocks are the focus. `if` is an accepted alias for `when`. Titles on the branches (`'authorized'`, `'denied'`) may vary in quote style but should be present. Penalize answers that place `when`/`else` outside `alt`, or that fall back to `notes`-only narrative branching instead of the real `alt` block." + }, + { + "id": 37, + "expected_output": "A paste-ready `dynamic view query-flow` snippet wrapping `service -> db 'query'` in a `try` block, followed by `catch 'on error' { service -> logger 'log failure' }` and `finally { service -> service 'release connection' }`, in that order.", + "files": [], + "expectations": [ + "The response uses a `dynamic view query-flow { ... }` block.", + "The snippet wraps `service -> db 'query'` inside a `try { ... }` block.", + "The snippet includes a `catch 'on error' { ... }` block with `service -> logger 'log failure'`.", + "The snippet includes a `finally { ... }` block with `service -> service 'release connection'`.", + "The blocks appear in the order `try` then `catch` then `finally`.", + "The response is primarily a usable DSL snippet, not only an explanation." + ], + "grading_guidance": "The try/catch/finally ordering is a hard constraint: `catch` after `finally`, or a standalone `catch`, is invalid. Accept minor title/quote variation on `catch`/`finally`. Penalize answers that reorder the blocks or emit `catch`/`finally` without a preceding `try`." + }, + { + "id": 38, + "expected_output": "The response starts with `Invalid.`, explains that the direct children of an `alt` block must be `when` / `if` / `else` branches so a bare `loop` cannot sit directly inside `alt`, and provides a corrected snippet that nests the `loop` inside a `when` (or `else`) branch.", + "files": [], + "expectations": [ + "The response starts with `Invalid.` exactly.", + "The response explains that `alt` accepts only branch blocks (`when` / `if` / `else`) as direct children.", + "The response states that `loop` cannot be a direct child of `alt` (it can not be used as an alternative branch).", + "The corrected snippet keeps the `loop` but nests it inside a `when` or `else` branch within the `alt` block.", + "The corrected snippet is minimal and syntactically valid LikeC4 DSL." + ], + "grading_guidance": "This is a contrastive placement-rule eval. The exact opening verdict `Invalid.` and the alt-branch rule are the primary signals. Reject answers that call the snippet valid, or that 'fix' it by removing the `loop` or by dropping the `alt` block entirely instead of nesting the loop inside a branch. `if` is an accepted alias for `when`." + }, + { + "id": 39, + "expected_output": "The response starts with a `description '''…'''` snippet that uses `[[cloud.ui]]`, `[[view:index]]` and, inside the table, `[[cloud.ui\\\\|Frontend]]`, then states that a missing target is a validation warning, not an error.", + "files": [], + "expectations": [ + "The response starts with the snippet (snippet-first), not prose.", + "The `description` uses a triple-quoted Markdown string (`'''` or `\"\"\"`), not a plain `'…'` string.", + "The sentence link is `[[cloud.ui]]` (full FQN, no label), so the element title is rendered.", + "The view link is `[[view:index]]`.", + "The table cell link escapes the label separator as `\\\\|`: `[[cloud.ui\\\\|Frontend]]`.", + "The response says a link to a missing element or view produces a warning (the model still builds; `likec4 validate` does not fail on it)." + ], + "grading_guidance": "Primary signals are the triple-quoted Markdown value, `[[view:index]]`, and the `\\\\|` escape inside the table. Reject a bare `|` inside the table cell, a single `\\|`, short names such as `[[ui]]`, `this`, or a Markdown `[text](url)` link instead of `[[…]]`. Do not require the `Frontend` label outside the table. Claiming that a broken link is a validation error is wrong." + } + ], + "default_execution_checks": [ + { + "name": "Validate executable LikeC4 snippet when response is runnable", + "description": "Provide objective evidence by validating the answer with LikeC4 when the response includes a coherent runnable snippet or fixture.", + "when": "Run this when the response contains one main LikeC4 code block that can be validated with minimal wrapping and without inventing task semantics.", + "instructions": [ + "Extract the main LikeC4 snippet from the response.", + "Create a temporary isolated LikeC4 project and add minimal surrounding structure only when necessary for syntactic validation.", + "Validate with the LikeC4 CLI in JSON mode and with layout checks disabled.", + "Use validator output as additional evidence for pass/fail decisions, while still grading task semantics from expectations." + ], + "success_signals": [ + "The snippet validates without syntax or semantic errors.", + "Validated output is consistent with the eval's requested LikeC4 construct." + ], + "failure_signals": [ + "Validation fails with parse, semantic, or unresolved-reference errors.", + "The snippet cannot be made runnable without rewriting core logic claimed by the response." + ] + }, + { + "name": "Penalize prose-only answers on snippet-first prompts", + "description": "When the prompt explicitly requires a command-first, snippet-first, paste-ready, one-snippet, or exact first-line answer, treat prose-only or prose-first outputs as a meaningful failure.", + "when": "Run this when the prompt asks for an exact command, a minimal paste-ready snippet, one DSL snippet only, or a strict first-line token/verdict/letter.", + "instructions": [ + "Check whether the answer delivers the required command/snippet/token as the first meaningful content instead of leading with explanatory prose.", + "If the prompt requires one snippet only or a command on the first line, treat prose-first structure as negative evidence even when later content is knowledgeable.", + "If the prompt asks for a strict one-line token or exact opening phrase, verify that contract before considering the rest of the answer." + ], + "success_signals": [ + "The first meaningful content is the requested command, snippet, or exact verdict token.", + "The answer stays snippet-first or command-first when the prompt explicitly requires it." + ], + "failure_signals": [ + "The answer starts with explanatory prose when the prompt requires command-first or snippet-first output.", + "The answer is prose-only even though the prompt requires a concrete snippet, command, or exact opening token." + ] + } + ], + "artifact_type": "grading-spec", + "schema_version": 2 +} diff --git a/.agents/skills/likec4-dsl/references/bridge-leanix-drawio.md b/.agents/skills/likec4-dsl/references/bridge-leanix-drawio.md new file mode 100644 index 0000000..3e941c5 --- /dev/null +++ b/.agents/skills/likec4-dsl/references/bridge-leanix-drawio.md @@ -0,0 +1,42 @@ +# LeanIX bridge, Draw.io profile, and agent boundaries + +This reference is for tasks that touch **LeanIX**, **Draw.io export**, or **bridge artifacts**. It complements `cli.md` and the main skill rules. + +## Canonical source of truth + +- **LikeC4 DSL** (`.c4` / `.likec4`) is canonical. The bridge derives LeanIX-shaped inventory and Draw.io annotations from the resolved model. +- **Agent Skills** teach DSL syntax and CLI contracts; they **do not** replace running `likec4 gen leanix …`, `likec4 sync leanix …`, or `likec4 export drawio --profile leanix`. + +## CLI commands (first-class) + +From the project root (see `@likec4/leanix-bridge` README for details): + +| Goal | Command | +| --------------------------------- | ------------------------------------------------------------------------ | +| Manifest + dry-run + report | `likec4 gen leanix dry-run -o out/bridge` | +| Sync workflow (dry-run / plan) | `likec4 sync leanix --dry-run -o out/bridge` | +| Apply sync to LeanIX API | `likec4 sync leanix --apply -o out/bridge` (requires `LEANIX_API_TOKEN`) | +| Draw.io with bridge-managed cells | `likec4 export drawio --profile leanix -o ./diagrams` | + +**Draw.io default profile** (`default`): no `bridgeManaged` / `likec4Id` in styles. + +**Draw.io LeanIX profile** (`leanix`): adds `bridgeManaged=true`, `likec4Id`, `likec4Kind`, `likec4ViewId`, optional `likec4ProjectId` on vertices; `likec4RelationId` on edges — for round-trip and LeanIX alignment. + +Other useful flags: `likec4 export drawio --roundtrip` (embed layout in DSL comments), `--all-in-one`, `--uncompressed`. + +## Round-trip mental model + +1. **Export** Draw.io with `--profile leanix` so cells carry stable LikeC4 identities. +2. **Parse** Draw.io back to DSL (`likec4` / generators): `likec4Id` on vertices and `likec4RelationId` on edges preserve FQN and relationship identity where the parser supports them (see `packages/generators` Draw.io tests). +3. **After LeanIX sync**, `manifestToDrawioLeanixMapping(manifest)` maps `likec4Id` ↔ LeanIX fact sheet / relation ids for re-export or tooling. + +Do not invent bridge JSON shapes; generate them via the CLI or `@likec4/leanix-bridge` APIs documented in the package README. + +## Mapping config (YAML / JSON) + +Custom **LeanIX mapping** merges over defaults (`factSheetTypes`, `relationTypes`, `metadataToFields`). Invalid shapes (arrays instead of objects, non-string values, unknown top-level keys) are rejected with explicit errors when the config is normalized via `mergeWithDefault` / validation in `@likec4/leanix-bridge`. + +## MCP vs bridge + +- **MCP** (`likec4 mcp`, `@likec4/mcp`) exposes **read/query** tools over the LikeC4 workspace model (elements, views, relationships). It does **not** replace `likec4 gen leanix` or `likec4 sync leanix`. +- For LeanIX artifacts, Draw.io leanix profile, or manifest dry-run, use the **CLI** (or programmatic `@likec4/leanix-bridge`) as documented above. diff --git a/.agents/skills/likec4-dsl/references/cli.md b/.agents/skills/likec4-dsl/references/cli.md new file mode 100644 index 0000000..7f24ed6 --- /dev/null +++ b/.agents/skills/likec4-dsl/references/cli.md @@ -0,0 +1,168 @@ +# CLI Reference + +`likec4` is npm package that provides a CLI tool for working with LikeC4 models. +Only essential commands/parameters are listed here, for full documentation use `likec4 help`, `likec4 --help`. +Examples use `bunx` to run the CLI, but you should use workspace's package manager (e.g. `bun`, `pnpm`, `npm`). +If workspace is not a npm project, use `bunx` (if available) -> `pnpx` (if available) -> `npx` as a fallback. + +If workspace already has `likec4` as a dependency, check its version from package.json, make sure it is at least 1.53.0. Pin the version `bunx likec4@1.53.0 ...` otherwise. + +## Common Commands and Frequent Mistakes + +### ✅ Correct commands (use these) + +| Task | Correct Command | +| ----------------- | --------------------------------------------------------------------- | +| Validate files | `bunx likec4 validate --json --no-layout --file [project-dir]` | +| Fail on warnings | `bunx likec4 validate --max-warnings 0 [project-dir]` | +| Start dev server | `bunx likec4 serve [project-dir]` | +| Export PNG | `bunx likec4 export png -o ./images [project-dir]` | +| Build static site | `bunx likec4 build -o ./dist [project-dir]` | +| List icons | `bunx likec4 list-icons` or `bunx likec4 list-icons --group tech` | +| Apply a diff | `bunx likec4 diff apply [--dry-run] [project-dir]` | +| Safe delete | `bunx likec4 safe-delete --dry-run [project-dir]` | + +### ❌ Common mistakes (avoid these) + +| Incorrect | Why it fails | Correct | +| ------------------------------------------- | -------------------------- | ---------------------------------------- | +| `bunx likec4 check ...` | Command doesn't exist | Use `bunx likec4 validate ...` | +| `bunx likec4 lint ...` | Command doesn't exist | Use `bunx likec4 validate ...` | +| `bunx likec4 verify ...` | Command doesn't exist | Use `bunx likec4 validate ...` | +| `bunx likec4 export png --out-dir ./images` | Unknown flag (`--out-dir`) | Use `-o ./images` or `--outdir ./images` | + +## `serve` (aliases: `start`, `dev`) + +Starts local server with live reload to preview diagrams (default port is 5173). + +```bash +bunx likec4 serve [project-dir] +bunx likec4 serve --port 3000 [project-dir] +``` + +When started, you can show the diagram to user in the browser by following the URL displayed in the console. +To navigate to specific view, use the URL path `/view/`. + +## `build` (alias: `bundle`) + +Build a static website for deployment. + +```bash +bunx likec4 build -o ./dist [project-dir] +``` + +Preview the build with `bunx likec4 preview -o ./dist [project-dir]`. + +## App config (experimental) + +Newer versions of the CLI read `likec4.app.config.ts` (also `.mts`, `.cts`, `.js`, `.mjs`, `.cjs`) from the project directory or the current directory. +Check that `likec4 build --help` lists `--app-config` before relying on it. + +```ts +// likec4.app.config.ts +import { defineAppConfig } from 'likec4/config' + +export default defineAppConfig({ + title: 'My Architecture', + theme: 'dark', // 'light' | 'dark' + base: '/pages/', + customization: './likec4.customization.tsx', // UI overrides, see docs + build: { outDir: './dist' }, + server: { port: 3000 }, +}) +``` + +- Read by `serve`, `build`, `preview`, `export png|jpg`, `codegen react`, `codegen webcomponent`. +- Flags win over the file. `--app-config ` points to another file, `--no-app-config` ignores it. +- Paths in the file are relative to the file; unknown options are errors. +- This is not `likec4.config.json` (the project configuration). + +## `export` + +Export diagrams to various formats. + +```bash +# PNG (requires Playwright) +bunx likec4 export png -o ./images [project-dir] +bunx likec4 export png --theme dark --flat -f "overview*" -o ./images [project-dir] + +# JSON model +bunx likec4 export json -o model.json --pretty --skip-layout [project-dir] + +# DrawIO +bunx likec4 export drawio --all-in-one -o ./diagrams [project-dir] +``` + +**export png** options: `--outdir` (`-o`), `--theme` [light|dark], `--flat`, `--filter` (`-f`, glob patterns), `--seq` (sequence layout for dynamic views), `--timeout` (default 15s) +**export json** options: `--outfile` (`-o`, default "likec4.json"), `--pretty`, `--skip-layout` +**export drawio** options: `--outdir` (`-o`), `--all-in-one`, `--roundtrip`, `--uncompressed`, `--profile` [default|leanix] + +### LeanIX bridge and Draw.io (LeanIX profile) + +Use these when the task is **LeanIX inventory**, **bridge artifacts**, or **Draw.io with stable bridge-managed ids** — not for generic DSL editing alone. + +| Task | Command | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Manifest + LeanIX dry-run + report | `bunx likec4 gen leanix dry-run -o out/bridge [project-dir]` | +| Sync workflow (review / apply) | `bunx likec4 sync leanix --dry-run -o out/bridge [project-dir]` / `bunx likec4 sync leanix --apply -o out/bridge [project-dir]` | +| Draw.io with `likec4Id`, `bridgeManaged`, etc. | `bunx likec4 export drawio --profile leanix -o ./diagrams [project-dir]` | + +Full boundaries, round-trip notes, and MCP vs bridge: **`references/bridge-leanix-drawio.md`**. + +## `codegen` (aliases: `gen`, `generate`) + +Generate code artifacts from the model. + +```bash +# TypeScript model (typed, with all views and elements) +bunx likec4 gen model -o likec4-model.ts [project-dir] + +# React component +bunx likec4 gen react -o dist/likec4-views.mjs [project-dir] + +# Web component JS bundle +bunx likec4 gen webcomponent -o likec4.js -w c4 [project-dir] + +# Diagram formats +bunx likec4 gen mermaid -o ./out # .mmd files +bunx likec4 gen plantuml -o ./out # .puml files +bunx likec4 gen d2 -o ./out # .d2 files +bunx likec4 gen dot -o ./out # .dot files (Graphviz) +``` + +Shared options: `--outfile`/`--outdir` (`-o`), `--project` (`-p`), `--use-dot` + +## `mcp` + +Start MCP (Model Context Protocol) server for AI tool integration. + +```bash +bunx likec4 mcp [workspace] # stdio transport (default) +bunx likec4 mcp --http [workspace] # HTTP transport on port 33335 +bunx likec4 mcp -p 1234 [workspace] # HTTP transport on custom port +``` + +Options: `--stdio` (default), `--http`, `--port` (`-p`, default 33335), `--use-dot` + +## `list-icons` + +List all available built-in icons. Fast, no workspace initialization needed. + +```bash +bunx likec4 list-icons # all icons, one group:name per line +bunx likec4 list-icons --format json # grouped JSON object +bunx likec4 list-icons --group aws # only AWS icons +bunx likec4 list-icons --group tech -f json # tech icons as JSON +``` + +Options: `--format` (`-f`, `text` default or `json`), `--group` (`-g`, one of: `aws`, `azure`, `gcp`, `tech`, `bootstrap`) + +Icon groups: `aws` (~307 icons), `azure` (~614), `gcp` (~216), `tech` (~2000), `bootstrap` (~2051). + +## `format` + +Format LikeC4 source files in-place. + +```bash +bunx likec4 format [workspace] +``` diff --git a/.agents/skills/likec4-dsl/references/configuration.md b/.agents/skills/likec4-dsl/references/configuration.md new file mode 100644 index 0000000..a236c44 --- /dev/null +++ b/.agents/skills/likec4-dsl/references/configuration.md @@ -0,0 +1,266 @@ +# Project Configuration Reference + +LikeC4 projects are defined by a config file. The file's location determines project scope — all `.c4` files in the directory (and subdirectories) belong to that project. + +## Config File Names + +LikeC4 recognizes (in any order): + +| Format | File names | +| ---------- | ------------------------------------------------------------ | +| JSON/JSON5 | `.likec4rc`, `.likec4.config.json`, `likec4.config.json` | +| JavaScript | `likec4.config.js`, `likec4.config.cjs`, `likec4.config.mjs` | +| TypeScript | `likec4.config.ts`, `likec4.config.cts`, `likec4.config.mts` | + +## JSON Config Schema + +Always include `$schema` for validation and autocomplete: + +```json +{ + "$schema": "https://likec4.dev/schemas/config.json", + "name": "my-project" +} +``` + +## All Options + +### `name` (required) + +Unique project identifier. Must not be `"default"`. Cannot contain `.`, `@`, or `#`. + +```json +{ "name": "cloud-platform" } +``` + +### `title` + +Human-readable project title. + +```json +{ "name": "cloud-platform", "title": "Cloud Platform Architecture" } +``` + +### `metadata` + +Arbitrary key-value pairs for custom project information. + +```json +{ + "metadata": { + "owner": "platform-team", + "domain": "payments", + "version": "2.0" + } +} +``` + +### `contactPerson` + +Person involved in creating or maintaining the project. + +```json +{ "contactPerson": "Jane Doe" } +``` + +### `include` + +Reference external directories containing `.c4` files. + +```json +{ + "include": { + "paths": ["../shared", "../common/specs"], + "maxDepth": 5, + "fileThreshold": 50 + } +} +``` + +| Property | Type | Default | Description | +| --------------- | ---------- | ---------- | -------------------------------------- | +| `paths` | `string[]` | (required) | Relative directory paths to scan | +| `maxDepth` | `number` | `3` | Directory scan depth (1–20) | +| `fileThreshold` | `number` | `30` | Warn if loaded file count exceeds this | + +### `exclude` + +Glob patterns (picomatch) to exclude files. Default: `["**/node_modules/**"]`. + +```json +{ "exclude": ["**/node_modules/**", "**/generated/**"] } +``` + +### `inferTechnologyFromIcon` + +Auto-derive `technology` from icon name when not set explicitly. Applies to `aws:`, `azure:`, `gcp:`, `tech:` icons. Default: `true`. + +```json +{ "inferTechnologyFromIcon": false } +``` + +### `implicitViews` + +Auto-generate scoped views for elements without explicit views, enabling drill-down navigation. Default: `false`. + +```json +{ "implicitViews": true } +``` + +### `imageAliases` + +Shortcuts for image directory paths. Default alias `@` points to `./images`. + +```json +{ + "imageAliases": { + "@": "./images", + "@shared": "../../shared-images" + } +} +``` + +### `manualLayouts` + +Configure where manual layout data is stored. + +```json +{ + "manualLayouts": { + "outDir": ".likec4" + } +} +``` + +`outDir` is relative to the config file location. Default: `".likec4"`. + +### `styles` + +Theme customization and default styling. + +```json +{ + "styles": { + "theme": { + "colors": { + "primary": "#FF6B6B", + "secondary": "rgba(37,99,235,1)" + } + }, + "defaults": { + "border": "dashed", + "opacity": 100, + "size": "md", + "relationship": { + "color": "gray", + "line": "dashed" + }, + "view": { + "routing": "ortho" + } + } + } +} +``` + +Set `styles.defaults.view.routing` to `spline` for curved relationships or `ortho` for right-angle bends. The default is `spline`. A view's `routing` property or `autoLayout` routing parameter overrides this project default. See [Edge routing](./views.md#edge-routing) for precedence and limitations. + +### `extends` + +Inherit styles from other config files (JSON only). Single path or array. + +```json +{ "extends": "../shared/likec4.config.json" } +{ "extends": ["../shared/base.json", "../shared/theme.json"] } +``` + +### `landingPage` + +Configure the landing page of the generated site. + +Redirect to index view: + +```json +{ "landingPage": { "redirect": true } } +``` + +Show only specific views: + +```json +{ "landingPage": { "include": ["overview", "cloud-detail"] } } +``` + +Hide specific views: + +```json +{ "landingPage": { "exclude": ["internal-debug"] } } +``` + +### `generators` (TypeScript/JS config only) + +Custom generators that produce output from the model. + +```typescript +import { defineConfig } from 'likec4' + +export default defineConfig({ + name: 'my-project', + generators: { + 'my-gen': async ({ likec4model, ctx }) => { + const elements = likec4model.elements() + await ctx.write({ + path: 'output.json', + content: JSON.stringify(elements, null, 2), + }) + }, + }, +}) +``` + +Run with: `likec4 gen my-gen` + +## Multi-Project Setup + +Each config file in the workspace defines a separate project. Files belong to the project of the nearest config file in the directory hierarchy. + +``` +workspace/ +├─ project-a/ +│ ├─ likec4.config.json ← project "a" +│ ├─ model.c4 +│ └─ views.c4 +├─ project-b/ +│ ├─ likec4.config.json ← project "b" +│ └─ model.c4 +└─ shared/ + └─ common.c4 ← included via "include.paths" +``` + +Use `include.paths` to share `.c4` files across projects. + +## Minimal Starter Config + +```json +{ + "$schema": "https://likec4.dev/schemas/config.json", + "name": "my-project", + "title": "My Architecture" +} +``` + +**Important:** Always include `"$schema"` (recommended) — it enables IDE autocomplete and validation for your config file. + +### Common Mistake: Missing $schema + +```json +// ❌ Missing $schema — no IDE validation/autocomplete +{ + "name": "my-project" +} + +// ✅ Correct — with schema for IDE support +{ + "$schema": "https://likec4.dev/schemas/config.json", + "name": "my-project" +} +``` diff --git a/.agents/skills/likec4-dsl/references/deployment.md b/.agents/skills/likec4-dsl/references/deployment.md new file mode 100644 index 0000000..9804e41 --- /dev/null +++ b/.agents/skills/likec4-dsl/references/deployment.md @@ -0,0 +1,160 @@ +# Deployment (LikeC4 DSL) + +The `deployment` block maps logical model elements to physical infrastructure nodes using `instanceOf`. It uses `deploymentNode` kinds from the specification. + +**Key rules:** + +- Deployment inherits ALL relationships from the logical model automatically. +- Additional deployment-level relationships can be defined inline (same syntax as in model). +- Use named instances (`id = instanceOf ELEMENT`) when multiple instances of the same element exist within the same deployment node. +- Anonymous `instanceOf ELEMENT` (no name) is fine when there is only one instance per node. +- In deployment-view filters, instance tags are cumulative with logical element tags. +- In deployment-view filters, instance metadata replaces logical element metadata when present; otherwise logical metadata is the fallback. + +## Syntax + +```likec4 +deployment { + IDENTIFIER = DEPLOYMENT_KIND { + TAGS + PROPERTIES + + // Anonymous instance — fine when there is only one instance per node + instanceOf ELEMENT_ID + + // Named instance — required when same element appears multiple times in the same node + IDENTIFIER = instanceOf ELEMENT_ID { + TAGS + PROPERTIES + } + } +} +``` + +## Basic Example + +```likec4 +specification { + element webapp + deploymentNode vm +} +model { + webapp myapp +} +deployment { + vm vm1 { + instanceOf myapp // anonymous: single instance + } + vm vm2 { + // Named: two replicas of the same logical element in one node + instance1 = instanceOf myapp + instance2 = instanceOf myapp + } +} +``` + +## Multi-Environment Pattern + +```likec4 +specification { + deploymentNode environment { notation "Environment"; style { color gray } } + deploymentNode zone { notation "Network Zone" } + deploymentNode vm +} + +deployment { + environment prod { + zone AppTier { + vm appVm { + primary = instanceOf cloud.api + secondary = instanceOf cloud.api // named: two replicas + } + } + zone DataTier { + vm dbVm { instanceOf cloud.db } + } + } + environment staging { + vm stagingApp { instanceOf cloud.api } + vm stagingDb { instanceOf cloud.db } + } +} +``` + +## Deployment Relationships + +Additional relationships can be defined between deployment nodes or named instances: + +```likec4 +deployment { + environment prod { + zone AppTier { + vm appVm { + primary = instanceOf cloud.api + } + } + zone DataTier { + vm dbVm { instanceOf cloud.db } + } + // Deployment-level relationship not in the logical model + AppTier -> DataTier "internal traffic" { technology "TCP/5432" } + // Bidirectional, `-[kind]<->` is also supported + AppTier <-> DataTier "replicates" + } +} +``` + +## Deployment View Filtering + +Deployment views use normal `where` filters, with deployment-aware endpoint values: + +- Deployment instance tags = logical element tags + instance tags. +- Deployment instance kind = logical element kind. +- Deployment instance metadata replaces logical metadata as a whole when present, otherwise logical metadata is the fallback. +- Nested children of a deployed instance use the logical child tags, kind, and metadata. +- Deployment node tags, kind, and metadata come from the deployment model. +- Tags are not inherited from parent deployment nodes. + +Relationship endpoint filters use those same rules: + +```likec4 +views { + deployment view prod { + include * -> * + exclude * -> * + where target.metadata.zone is "restricted" + } +} +``` + +Use unqualified `metadata.*` for the relationship's own metadata; use `source.metadata.*` or `target.metadata.*` for endpoint metadata. + +## Deployment View Navigation + +Element `with { navigateTo VIEW_ID }` overrides are supported for deployment nodes and instances: + +```likec4 +views { + deployment view prod { + include server with { + navigateTo server_deployment + } + } + deployment view server_deployment { + include server.* + } +} +``` + +The target must be an existing view. Overrides are view-local, support selectors and `where` filters, +and the last matching navigation override wins. Other element `with` properties remain unsupported; +use local `style` rules for deployment-view styling. + +## Named vs. Anonymous: When It Matters + +| Scenario | Use | +| ----------------------------------------------- | --------------------------------------------- | +| Single instance of element in node | Anonymous: `instanceOf cloud.api` | +| Multiple instances of same element in same node | Named: `primary = instanceOf cloud.api` | +| Strict eval requires named instance identifier | Named: `IDENTIFIER = instanceOf ELEMENT` | +| Deployment view needs to distinguish replicas | Named: each gets a unique node in the diagram | diff --git a/.agents/skills/likec4-dsl/references/diffs.md b/.agents/skills/likec4-dsl/references/diffs.md new file mode 100644 index 0000000..d161090 --- /dev/null +++ b/.agents/skills/likec4-dsl/references/diffs.md @@ -0,0 +1,52 @@ +# Model diffs and safe delete + +A `diff` is a named, planned change of the model (for example, an RFC). +The model does not change until the diff is applied. + +```likec4 +diff rfc1 'RFC-1: Payments service' { + description 'Replace the SOAP gateway with a Payments service' + + remove { + shop.legacyPayments // element, its descendants and their relationships + shop.orders -> bank 'checks fraud score' // relationship: source, target, kind, title and direction must match + } + + add { + extend shop { + payments = container 'Payments Service' + } + shop.orders -> shop.payments 'requests payments' + extend shop.orders { + #next // tags, links and metadata for an existing element + } + } +} +``` + +## Rules + +- `remove` lists elements and relationships that exist in the model. Use full FQNs. +- `add` has the syntax of a `model` block: elements, relationships, `extend` of elements. +- A diff cannot extend relationships, cannot remove elements added by a diff, and cannot add an element that exists. +- Diffs that change the same elements or relationships, or use what another diff adds, overlap: + both get a warning, and `diff apply` needs `--accept-overlaps` after the owners of the diffs agreed. +- Diffs with the same name in several files are merged. +- Views can reference elements added by a diff: the model "as is" ignores these references. + +## Apply, discard, safe delete + +```sh +bunx likec4 diff list [project-dir] +bunx likec4 diff apply rfc1 --dry-run [project-dir] # print the changes of each file +bunx likec4 diff apply rfc1 [project-dir] # merge `add` into the model, remove `remove` entries, delete the diff +bunx likec4 diff apply rfc1 --accept-overlaps [project-dir] # only after the owners of overlapping diffs agreed +bunx likec4 diff discard rfc1 [project-dir] # delete the diff, the model does not change +bunx likec4 safe-delete shop.legacyPayments --dry-run [project-dir] +bunx likec4 safe-delete shop.legacyPayments --orphans --scoped-views --dynamic-steps [project-dir] +``` + +If `likec4 diff --help` fails, the installed version predates model diffs. +`apply` and `safe-delete` never edit other diffs: they report the diffs they break (exit code 1). +`apply` keeps only the model changes: the title, description and comments of the diff are deleted with it. +Comments inside the braces of an added element move with the element. Put lasting notes in an element `description`. diff --git a/.agents/skills/likec4-dsl/references/dynamic-views.md b/.agents/skills/likec4-dsl/references/dynamic-views.md new file mode 100644 index 0000000..ea17330 --- /dev/null +++ b/.agents/skills/likec4-dsl/references/dynamic-views.md @@ -0,0 +1,455 @@ +# Dynamic Views — Flow & Sequence Diagrams + +Dynamic views model temporal interactions and flows between elements. They render as animated flow diagrams or (optionally) UML sequence diagrams. + +## Syntax Overview + +```likec4 +views { + dynamic view name { + variant sequence // optional: render as UML sequence diagram + + title "Flow title" + description "Flow description" + + SOURCE -> TARGET "step title" + SOURCE <- TARGET "return flow" + + // flow-control blocks (each takes an optional title, can nest) + parallel 'optional' { // also: par, opt, loop, break + SOURCE_1 -> TARGET_1 "parallel action 1" + SOURCE_2 -> TARGET_2 "parallel action 2" + } + alt 'optional' { // mutually exclusive branches + when 'condition' { SOURCE -> TARGET } + else { SOURCE -> TARGET } + } + try 'optional' { SOURCE -> TARGET } + catch 'optional' { SOURCE -> TARGET } + finally 'optional' { SOURCE -> TARGET } + } +} +``` + +## Basic Steps + +### Forward step + +```likec4 +dynamic view checkout-flow { + customer -> frontend "opens cart" + frontend -> backend "requests checkout" + backend -> payment-service "initiates payment" + payment-service -> bank "authorizes card" +} +``` + +- Renders as directed arrow from source to target. +- Title appears on the arrow/step. + +### Return step (response arrow) + +```likec4 +dynamic view checkout-flow { + customer -> frontend "opens cart" + frontend -> backend "requests checkout" + frontend <- backend "payment result" // Return flow +} +``` + +- `<-` denotes response/return flow. +- Renders as dotted or dashed arrow depending on theme. +- Semantically indicates flow **back** to the source. + +### Chained steps + +```likec4 +dynamic view multi-hop { + customer + -> frontend "request" + -> backend "forwards" + -> db "query" +} +``` + +Keep the chain as one compound expression when the prompt explicitly asks for chained syntax. + +### Hop-local body (exactness for evals) + +```likec4 +dynamic view checkout-flow { + customer -> frontend + -> api { + technology 'HTTPS' + navigateTo payment-detail + } +} +``` + +Attach the body only to the requested hop. Do not move the block onto the whole chain unless the prompt allows it. + +## Flow Control Blocks + +Steps can be grouped into **flow-control blocks**. `parallel` is one of these blocks — not a special case. Every block: + +- takes an **optional title** (a `String`) right after the keyword, then wraps a `{ ... }` body of steps; +- may be **nested to any depth** — the only exception is `parallel`/`par`, which cannot nest inside another `parallel`/`par`. + +Block keyword map: + +| Block | Keyword(s) | Meaning | +| --------------------- | --------------------------- | ----------------------------------------------- | +| Parallel | `parallel` / `par` | Steps inside run concurrently | +| Optional | `opt` | Steps that may be skipped | +| Loop | `loop` | Steps that repeat | +| Break | `break` | Interrupts the enclosing flow (e.g. exits loop) | +| Alternatives | `alt` | Container of mutually exclusive branches | +| Branch (inside `alt`) | `when` / `if` / `else` | One branch of an `alt` (`if` is a `when` alias) | +| Error handling | `try` / `catch` / `finally` | Happy path plus optional error handling | + +### Parallel — `parallel` / `par` + +Steps inside run concurrently. Use for fan-outs (one source to multiple targets): + +```likec4 +dynamic view fan-out { + backend -> cache "write to cache" + parallel { + backend -> db "save record" + backend -> audit-log "log event" + backend -> notification-service "send notification" + } +} +``` + +- `par { ... }` is an exact alias for `parallel { ... }`. +- Renders with time-aligned horizontal layout. +- **Cannot nest**: `parallel { parallel { ... } }` (or with `par`) is a validation error — `Nested parallel blocks are not allowed`. A `parallel` block may still live inside `opt` / `loop` / `try` / an `alt` branch. +- When the prompt asks for one fan-out, keep sibling actions in **one** `parallel { ... }` block; do not split them into multiple one-step blocks. + +### Optional — `opt` + +A block of steps that may be skipped: + +```likec4 +opt 'if not cached' { + api -> db 'load and cache' +} +``` + +### Loop — `loop` + +A block of steps that repeats: + +```likec4 +loop 'until success' { + api -> auth 'retry authentication' +} +``` + +### Break — `break` + +Interrupts the enclosing flow (typically exits a `loop`): + +```likec4 +loop 'poll for result' { + api -> db 'check status' + break 'when ready' { + api -> web 'return result' + } +} +``` + +### Alternatives — `alt` with `when` / `if` / `else` + +`alt` groups mutually exclusive branches. Its **direct children must be branches** — `when`, `if`, or `else` (with an optional title each): + +```likec4 +alt { + when 'authorized' { + app -> api 'requests data' + } + else 'not authorized' { + app -> customer 'shows login' + } +} +``` + +- `if` is an alias for `when`; both are branch keywords. +- `when` / `if` / `else` are only valid **inside** `alt`. Using them at the top level or inside `loop` / `parallel` / `opt` is a validation error (`"when" alternative branch must be inside "alt"`). +- Putting a non-branch block (`loop`, `opt`, `parallel`, `try`) as a **direct** child of `alt` is a validation error (`"loop" can not be used as an alternative branch`). Nest those blocks **inside** a branch instead. + +### Try / catch / finally — `try` + +Models the happy path with optional error handling. `catch` and `finally` are both optional, but order is fixed: `try` → `catch?` → `finally?`. + +```likec4 +try { + api -> db 'query' +} catch 'on failure' { + api -> web 'shows error' +} finally { + api -> api 'release resources' +} +``` + +- `try` alone, `try { } catch { }`, `try { } finally { }`, and `try { } catch { } finally { }` are all valid. +- A `catch` with no preceding `try`, or a `catch` after `finally`, is a validation error. +- `try` cannot be a direct child of `alt` (only branches can) — put it inside a `when` / `else` branch. + +### Nesting + +Blocks combine and nest to any depth (except `parallel` inside `parallel`): + +```likec4 +dynamic view resilient-sync { + variant sequence + loop 'until synced' { + try { + alt { + when 'online' { + web -> api 'sync changes' + } + else 'offline' { + opt { + web -> web 'queue locally' + } + } + } + } finally { + web <- api 'ack' + } + } +} +``` + +## Step Properties (Body) + +Attach a body block `{ ... }` to a step for additional properties: + +```likec4 +dynamic view with-notes { + customer -> frontend "view product" { + title "View Product Detail" + technology "HTTPS" + description "Customer opens product page" + } + + frontend -> backend "fetch product data" { + notes """ + Includes: + - Product info + - Pricing + - Available inventory + """ + } +} +``` + +**Available properties inside step body:** + +- `title` — alternative or longer title +- `technology` — technology/protocol used +- `description` — detailed description +- `notes` — markdown-formatted notes/commentary +- `navigateTo` — drill-down link to another dynamic view (see below) + +A step body does **not** accept `metadata` (parse error). Put metadata on the model relationship instead. + +## Navigation & Drill-down + +Link to another view from a step: + +```likec4 +dynamic view high-level { + customer -> frontend "browse" + frontend -> backend "request data" { + navigateTo data-fetch-details // Link to another view + } +} + +dynamic view data-fetch-details { + // Detailed flow of the "data-fetch" step +} +``` + +- `navigateTo ` — renders as a clickable link to drill down. +- On a dynamic step, `navigateTo` resolves **only** to another `dynamic view`. Pointing it at an element view fails with `Could not resolve reference to DynamicView`. +- Use for progressive disclosure: high-level flows → detailed sub-flows. + +## Variant: Sequence Diagram + +Render the dynamic view as a UML-style sequence diagram: + +```likec4 +dynamic view checkout-sequence { + variant sequence // Switch to sequence diagram rendering + + customer -> frontend "click checkout" + frontend -> backend "POST /checkout" + backend -> payment-service "charge card" + payment-service <- bank "authorization" + backend <- payment-service "charge approved" + frontend <- backend "200 OK" + customer <- frontend "show confirmation" +} +``` + +- `variant sequence` — tells LikeC4 to render as sequence diagram. +- Timeline flows top-to-bottom (instead of left-to-right flow diagram). +- Actors (lifelines) are participants; return arrows are dashed. + +### Comparing flow vs. sequence rendering + +| Aspect | Flow Diagram | Sequence Diagram (variant sequence) | +| ------------- | ------------------------------ | ----------------------------------- | +| Layout | Left-to-right or top-down flow | Top-to-bottom lifelines | +| Return arrows | Style varies | Dashed lines standard | +| Parallel feel | Horizontal alignment | Time-based with spacing | +| Best for | System interactions | Message exchange protocols | + +## Response Arrow Patterns (exactness) + +If the prompt asks for response arrows back out, prefer `<-` steps over inventing extra forward steps: + +```likec4 +dynamic view checkout-flow { + customer -> frontend -> api + frontend <- api // One <- step per response hop + customer <- frontend // <- cannot be chained (`a <- b <- c` is a parse error) +} +``` + +NOT: + +```likec4 +dynamic view checkout-flow { + customer -> frontend -> api + api -> frontend "returns" // Wrong: not a response arrow (missing symmetry) + frontend -> customer "returns" +} +``` + +## Common Patterns + +### Fan-out with return + +```likec4 +dynamic view fan-out-return { + backend -> cache "check" + backend -> db "if miss" + backend <- db "result" + backend -> client "send" +} +``` + +Order: send to multiple targets, then collect responses separately when they differ. + +### Pipeline / staged flow + +```likec4 +dynamic view data-pipeline { + source -> ingester "submit" + ingester -> parser "parse" + parser -> validator "validate" + validator -> storage "store" + validator <- storage "ack" +} +``` + +- Linear chain: each stage outputs to the next. +- Use for ETL/processing workflows. + +### Conditional/branching + +Prefer an `alt` block with `if` / `when` / `else` branches — this renders as an explicit alternative frame in the `sequence` variant: + +```likec4 +dynamic view payment-decision { + customer -> frontend "enter amount" + frontend -> backend "validate" + alt { + when 'within limit' { + backend -> payment-service "charge" + backend <- payment-service "result" + } + else 'over limit' { + backend -> frontend "rejected" + } + } +} +``` + +- Reach for `opt` for an "if without else" (a step group that may be skipped), and `try` / `catch` for success-vs-failure paths. +- `notes` on a step remains a lightweight way to document decision logic when a full branch block is not warranted. + +## Anti-Patterns to Avoid + +| Anti-pattern | Problem | Solution | +| ------------------------------------------------- | ------------------------------------------- | --------------------------------------------------- | +| Nested `parallel { parallel { ... } }` | `Nested parallel blocks are not allowed` | Put all concurrent steps in one `parallel {}` | +| `when` / `if` / `else` outside `alt` | `alternative branch must be inside "alt"` | Wrap branches in an `alt { ... }` block | +| `loop`/`opt`/`parallel`/`try` direct in `alt` | `can not be used as an alternative branch` | Nest them inside a `when` / `else` branch | +| `catch` without `try`, or `catch` after `finally` | Invalid try/catch/finally order | Keep order `try` → `catch?` → `finally?` | +| Too many steps in one view | Diagram becomes unreadable | Split into sub-views with `navigateTo` | +| Using `<-` for side-by-side actions | Implies return; violates sequence semantics | Use forward `->` or group with `parallel` | +| Forgetting `variant sequence` when UML needed | Wrong rendering | Add `variant sequence` if sequence diagram required | + +## Predicate Filters in Dynamic Views + +Dynamic views do not use predicates to generate or filter interaction steps. Steps must be listed explicitly. + +You may still use normal include predicates to add context elements that do not participate in steps: + +```likec4 +dynamic view critical-flow { + frontend -> api "request" + api -> db "query" + + include cloud.* where tag is #critical + include cloud.* where metadata.region is "eu" +} + +dynamic view with-cache { + // Alternative flow with cache layer + frontend -> cache "check" + cache -> backend "if miss" +} +``` + +## Reference and Examples + +Full LikeC4 dynamic view docs: https://likec4.dev/dsl/views/dynamic/ + +Common scenarios: + +- **Authentication flow** — customer → login → auth-service → database +- **CQRS pattern** — command → service → write-db (parallel: query-handler → read-db) +- **Webhook callback** — external-system → api (parallel within with navigateTo service-webhook-handler) +- **Pub/Sub flow** — publisher → message-bus → consumer (with parallel for multiple subscribers) + +When the prompt asks for one parallel fan-out, keep sibling actions inside one `parallel { ... }` block. Do not split the three steps into separate one-step blocks. + +### Sequence variant + +```likec4 +dynamic view checkout-flow { + variant sequence + customer -> frontend -> api + frontend <- api + customer <- frontend +} +``` + +Use `variant sequence` when the prompt explicitly asks for UML-style sequence rendering. + +## Quick anti-patterns + +- Rewriting a requested chain as separate top-level steps +- Using forward arrows where the prompt explicitly asks for response arrows back out +- Spreading one requested parallel fan-out across multiple `parallel` blocks +- Omitting required hop-local tokens such as `technology` or `navigateTo` +- Placing `when` / `if` / `else` anywhere but directly inside `alt` +- Putting `loop` / `opt` / `parallel` / `try` as a direct child of `alt` instead of inside a branch +- Nesting `parallel` inside `parallel` (only block that cannot nest) +- Breaking try/catch/finally order (`try` → `catch?` → `finally?`) diff --git a/.agents/skills/likec4-dsl/references/examples.md b/.agents/skills/likec4-dsl/references/examples.md new file mode 100644 index 0000000..47daadd --- /dev/null +++ b/.agents/skills/likec4-dsl/references/examples.md @@ -0,0 +1,370 @@ +# Examples + +Compact, real-world patterns. Each example demonstrates multiple features. + +## Extend Element & Metadata Merge + +```likec4 +// base.c4 +model { + cloud = system "Cloud" { + api = service "API" { + metadata { owner "team-a"; port "8080" } + } + } +} + +// ops.c4 — extend adds nested elements, merges metadata (duplicate keys become arrays) +model { + extend cloud.api { + #monitored + metadata { port "9090"; region "us-east-1" } + // Result: port becomes ["8080", "9090"] + + health = component "Health Check" { technology "HTTP" } + } +} +``` + +## Extend Relationship + +```likec4 +specification { + relationship async { color amber; line dotted } +} +model { + frontend -> api "requests" + frontend -[async]-> api "streams" // different relation (kind distinguishes) + + // Extend adds metadata/tags to existing relation (must match source, target, title, kind) + extend frontend -> api "requests" { + metadata { latency_p95 "150ms" } + } +} +``` + +## View Extends (Inheritance) + +```likec4 +views { + view overview { + include * + style * { color muted } + } + view detail extends overview { + title "More detail" + include cloud.backend.* // adds to parent's includes + style cloud.backend.* { color primary } + } + view deep extends detail { // cascade inheritance + include cloud.backend.api.* + } +} +``` + +## Scoped View + +```likec4 +views { + // "of" scopes the view — `*` means the scoped element + direct children + view backend of cloud.backend { + include * + include -> cloud.backend -> // incoming/outgoing relations + + style cloud.backend { color primary } + style cloud.backend.* { color secondary } + } +} +``` + +## Groups in Views + +```likec4 +views { + view grouped { + group "Internal" { + color primary + opacity 20% + include cloud.* + } + group "External" { + color amber + include customer, partner + } + // Unnamed group + group { + include monitoring.* + } + } +} +``` + +## Global Style & Predicate Groups + +```likec4 +global { + styleGroup theme { + style element.tag = #deprecated { color muted; opacity 20% } + style element.tag = #critical { color red } + } + predicateGroup core_services { + include cloud.* where kind is service + exclude * where tag is #deprecated + } +} +views { + global style theme // apply to all views in block + + view services { + global predicate core_services + include * -> cloud.* // add relations to included elements + } +} +``` + +## Dynamic View — Parallel, Notes, NavigateTo + +```likec4 +dynamic view checkout { + title "Checkout Flow" + + customer -> frontend "places order" { + notes ''' + **Entry point**: customer submits cart + ''' + } + frontend + -> api "POST /checkout" + -> validator "validate cart" // chained syntax + + parallel { + api -> payments "charge card" + api -> inventory "reserve items" + api -> notifications "send confirmation" + } + + api <- payments "payment result" { + navigateTo payment-detail // link to another view + } + + include cloud with { opacity 10%; color gray } + style customer { color green } + autoLayout TopBottom +} +``` + +## Dynamic View — Flow Control (alt / try / loop) + +```likec4 +dynamic view resilient-checkout { + variant sequence + + customer -> app "checkout" + alt { + when "authorized" { + loop "until confirmed" { + try { + app -> payments "charge card" + } catch "declined" { + app -> customer "ask for another card" + } finally { + app -> audit "record attempt" + } + } + } + else "not authorized" { + app -> customer "show login" + } + } +} +``` + +- Each block takes an optional title. Blocks nest to any depth — the only exception is `parallel`/`par`, which cannot nest. +- `when`/`if`/`else` are valid only directly inside `alt`; other blocks (`loop`/`opt`/`parallel`/`try`) must sit inside a branch, not directly under `alt`. +- try/catch/finally order is fixed: `try` → `catch?` → `finally?`. + +## Deployment — Multi-Environment, InstanceOf + +```likec4 +specification { + deploymentNode environment { style { color gray } } + deploymentNode region + deploymentNode vm +} +deployment { + environment prod "Production" { + technology "OpenTofu" + + region eu { + vm server1 { + app = instanceOf cloud.api // named instance + instanceOf cloud.ui // anonymous instance + } + vm server2 { + db = instanceOf cloud.db { + title "Primary DB" + technology "PostgreSQL 16" + } + } + } + region us { + vm server3 { + db = instanceOf cloud.db "Replica DB" + } + // Deployment-specific relation (replication) + server3.db -> eu.server2.db "replicates" { + metadata { lag "100ms"; mode "async" } + } + } + } +} +views { + deployment view prod_deploy { + title "Production" + include prod.** + style eu._ { color primary } + style us._ { color secondary } + } +} +``` + +## Rank — Layout Control + +```likec4 +views { + view ranked { + include A, B, C, D, E, F + + rank same { A, B } // same vertical level + rank source { C, D } // force to top + rank max { F } // force to bottom + } +} +``` + +## Where Predicates — Filtering + +```likec4 +views { + view filtered { + // By tag + include cloud.* where tag is #production + + // By kind + negated tag + include cloud.* where kind is service and tag is not #deprecated + + // By metadata + include cloud.* where metadata.region is "eu" + + // Relationship filtering, including own vs endpoint metadata + include cloud.* -> amazon.* where source.tag is #critical + exclude * -> * where metadata.protocol is "http" + exclude * -> * where target.metadata.zone is "restricted" + + // Wildcard with expansion + include cloud._ // direct children that have relationships with the accumulated result + include cloud.** // all descendants + } +} +``` + +## Relationship Kinds & Styling + +```likec4 +specification { + relationship async { color amber; line dotted; head diamond; tail vee } + relationship sync { color primary; line solid } +} +model { + api -[async]-> queue "publishes" { + technology "Kafka" + metadata { format "CloudEvents" } + } + api .sync cache "reads" // alternative syntax: .KIND (no arrow) + + // Inline style override + api -> db "writes" { + style { color red; line dashed; head odiamond } + } +} +``` + +## Complete Mini-Project + +```likec4 +specification { + element actor { style { shape person } } + element system { style { shape rectangle } } + element service { style { shape component } } + element webapp { style { shape browser } } + element db { style { shape storage } } + + tag deprecated + tag critical + + relationship async { color amber; line dotted } + deploymentNode env + deploymentNode vm +} + +model { + customer = actor "Customer" + + cloud = system "Cloud" { + ui = webapp "Frontend" { icon tech:react } + api = service "API" { + #critical + technology "Node.js" + icon tech:nodejs + } + db = db "Database" { + icon tech:postgresql + description ''' + Primary PostgreSQL database. + Handles all transactional data. + ''' + } + api -> db "reads/writes" + ui -> api "calls" { technology "HTTPS" } + } + + customer -> cloud.ui "browses" { + navigateTo user-flow + metadata { protocol "HTTPS" } + } +} + +deployment { + env prod { + vm web { instanceOf cloud.ui; instanceOf cloud.api } + vm data { instanceOf cloud.db } + web -> data "internal network" + } +} + +views { + view index { + include * + style cloud { color primary } + style customer { color green } + } + + view backend of cloud { + include * + autoLayout LeftRight + } + + dynamic view user-flow { + customer -> cloud.ui "opens app" + cloud.ui -> cloud.api "fetches data" + cloud.api -> cloud.db "queries" + cloud.api -> cloud.ui "returns" + + style cloud { opacity 20% } + } + + deployment view prod-deploy { + include prod.** + } +} +``` diff --git a/.agents/skills/likec4-dsl/references/identifier-validity.md b/.agents/skills/likec4-dsl/references/identifier-validity.md new file mode 100644 index 0000000..76e4e24 --- /dev/null +++ b/.agents/skills/likec4-dsl/references/identifier-validity.md @@ -0,0 +1,90 @@ +# Identifier Validity (LikeC4 DSL) + +## Valid identifier form + +``` +identifier = /^[a-zA-Z_][a-zA-Z0-9_-]*$/ +``` + +- **Starts with** letter or underscore +- **Contains** letters, digits, hyphens, or underscores only +- **No dots**, no spaces, no special characters + +## Valid examples + +| Valid | Why | +| ----------------- | ----------------------------- | +| `payment-api` | hyphens allowed | +| `paymentAPI` | camelCase allowed | +| `payment_service` | underscores allowed | +| `service1` | digits allowed (not at start) | +| `_internal` | underscore at start allowed | +| `PaymentService` | PascalCase allowed | + +## Invalid examples + +| Invalid | Why | Correct form | +| ------------- | ------------------------------------------------- | ----------------------------- | +| `payment.api` | **dots are FQN separators**, not identifier chars | `payment-api` or `paymentApi` | +| `payment api` | spaces not allowed | `payment-api` | +| `1payment` | cannot start with digit | `payment1` | +| `payment-` | cannot end with hyphen | `payment` | +| `payment@api` | special chars not allowed | `payment-api` | + +## Critical: dots vs. hyphens + +**Dots are reserved for fully qualified names (FQN):** + +```likec4 +model { + cloud.backend.payment-api // FQN: cloud (parent) -> backend (child) -> payment-api (child identifier) + // ^ ^ Dots separate hierarchy levels + // |---- hyphen used in identifier name itself +} +``` + +**Correct identifier:** `payment-api` (with hyphen)\ +**Incorrect identifier:** `payment.api` (dots are FQN syntax, not part of the name) + +## Use in different contexts + +| Context | Rule | Example | +| --------------------- | --------------------------------- | ---------------------------------------- | +| **Element name** | Must be valid identifier | `service name = api "payment api"` ✓ | +| **Tag name** | Must be valid identifier | `tag critical` ✓, `tag #critical` ✗ | +| **Relationship kind** | Must be valid identifier | `-[async]->` ✓, `-[async-queue]->` ✓ | +| **Kind name** | Must be valid identifier | `element service`, `deploymentNode vm` ✓ | +| **Metadata key** | Must be valid identifier | `metadata { api_version "1.0" }` ✓ | +| **Color name** | Must be valid identifier (or hex) | `color primary`, `color custom-blue` ✓ | + +## When referencing by FQN + +Once defined, elements are referenced using their fully qualified names (FQNs), which _do_ use dots: + +```likec4 +model { + // Definition (identifier = payment-api, no dots) + cloud = system { + backend = container { + payment-api = service "Payment API" + } + } +} + +deployment { + vm1 { + // Reference (FQN = cloud.backend.payment-api, dots for hierarchy) + instanceOf cloud.backend.payment-api + } +} +``` + +## Summary + +| Check | Valid | Invalid | +| ---------------- | --------------------------------------------------------------------------------- | --------------------------------------- | +| Identifier rule | `[a-zA-Z_][a-zA-Z0-9_-]*` | Contains dots, spaces, or special chars | +| Hyphens allowed? | **Yes** in identifiers | Not dots | +| Dots allowed? | **Only in FQNs** (for scope separation) | Never in a single identifier name | +| `payment-api` | ✓ identifier | — | +| `payment.api` | ✗ identifier, but valid as FQN if `payment` and `api` are nested parents/children | — | diff --git a/.agents/skills/likec4-dsl/references/include-predicates-wildcards.md b/.agents/skills/likec4-dsl/references/include-predicates-wildcards.md new file mode 100644 index 0000000..849da97 --- /dev/null +++ b/.agents/skills/likec4-dsl/references/include-predicates-wildcards.md @@ -0,0 +1,180 @@ +# Include Predicates and Wildcards in Views + +## Wildcard semantics + +LikeC4 view predicates use the wildcard `*` and the selector suffixes `parent.*`, `parent._` and `parent.**`. +`_` and `**` exist **only** as suffixes on an element reference: bare `include **` and `exclude _` are parse errors. + +### `*` — immediate children and direct contains-relationships + +```likec4 +views { + view container-overview { + include * + } +} +``` + +- Includes **direct children** of the scoped element (one level down). +- Includes **direct relationships** defined at the same scope. +- Does **not** include grandchildren or descendant chains. + +### `parent._` — expand (children that have relationships) + +```likec4 +views { + view connected-children { + include customer + include cloud._ + } +} +``` + +- Includes `cloud` and only those direct children of `cloud` that have relationships with visible elements; other children are omitted. +- Same as `include cloud` + `include -> cloud.* ->`. +- `_` is **not** a "match anything" wildcard. To exclude every visible element with a tag, use `exclude * where tag is #deprecated`. + +### `parent.**` — recursive descent (descendants) + +```likec4 +views { + view full-tree { + include cloud.** + } +} +``` + +- Includes **all descendants** recursively (grandchildren, great-grandchildren, etc.). +- **Not** the same as `include cloud.*` (which is immediate children only). +- Use when you want nested hierarchies visible in a single view. + +## Scoped view base semantics + +In a **scoped view** (`view name of parent { ... }`), the `include` base set has special meaning: + +```likec4 +views { + view backend-overview of cloud.backend { + include * // Base set: cloud.backend + its immediate children + include -> cloud.backend // Add: inbound relationships to the scope + include -> * // Add: outbound relationships from children + } +} +``` + +**Key facts:** + +- `include *` in a scoped view = the scoped parent + **direct children only** +- Grandchildren inside `*` context are **not** included (you'd need `include cloud.backend.**` or explicit FQN inclusion) +- Relationship predicates like `->` and `<->` can further expand what neighbors appear + +## Common filter patterns + +### Show a container and its components + +```likec4 +views { + view container-details of cloud.backend.api { + include * // api (parent) + all components + include -> api // Add external callers + } +} +``` + +### Show descendants recursively + +```likec4 +views { + view system-tree of cloud { + include cloud.** // All descendants of cloud (full tree) + } +} +``` + +### Filter by kind + +```likec4 +views { + view services-only { + include * where kind is service + include cloud.** where kind is service // All services at any depth inside cloud + } +} +``` + +### Filter by tag + +```likec4 +views { + view critical-view { + include * where tag is #critical + exclude * where tag is #deprecated // Removes deprecated elements at any depth + } +} +``` + +## Relationship predicate expansion + +In scoped views, you can expand what neighboring elements appear via relationship predicates: + +```likec4 +views { + view backend-with-neighbors of cloud.backend { + include * // Base: backend scope + direct children + include -> cloud.backend // Add: inbound edges from outside scope + } +} +``` + +This **does not change the base set of elements**; it only **includes relationships** that end in the visible elements. Neighbors of the scope that have incoming edges _do_ appear because they are needed to render those edges visually. + +## Wildcard `**` vs. explicit include + +### Using `**` (recursive) + +```likec4 +views { + view all-elements { + include cloud.** + } +} +``` + +- Automatically includes all descendants. +- Used when hierarchy depth is unknown or variable. + +### Using `*` + explicit FQNs + +```likec4 +views { + view two-levels { + include * // Level 1 + include cloud.* // Level 2 under cloud + } +} +``` + +- More explicit; requires knowing the hierarchy. +- Useful when you want fine-grained control. + +## Common mistakes + +| Wrong | Right | Why | +| ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------- | +| Bare `include **` or `exclude _` | `include cloud.**`, `exclude * where tag is #deprecated` | `**` and `_` are suffixes on an element reference, not standalone wildcards | +| `include cloud.**` expecting only immediate children | `include cloud.*` | `**` is recursive; use `*` for one level | +| Forgetting to add relationship filters in scoped view | `include *` + `include -> parent` | Relationships may not auto-render without explicit inclusion | +| Assuming `include *` includes grandchildren | Use `include cloud.**` instead | `*` is immediate children only | + +## Summary table + +| Form | Matches | Scope context | +| -------------------- | ------------------------------------------------------------------------ | ---------------------------- | +| `*` | Direct children + sibling relationships | One level down | +| `parent._` | `parent` + direct children that have relationships with visible elements | One level down | +| `parent.**` | All descendants recursively | Any depth | +| `include -> element` | Inbound relationships to element | Shows external sources | +| `include element ->` | Outbound relationships from element | Shows targets | +| `include a <-> b` | Relationships between `a` and `b` in either direction | Shows symmetric dependencies | + +**In scoped views:** `include *` establishes a base set of the scope parent + immediate children. Relationship predicates like `->` and `<->` can bring in neighboring elements needed to render those relationships. diff --git a/.agents/skills/likec4-dsl/references/model.md b/.agents/skills/likec4-dsl/references/model.md new file mode 100644 index 0000000..30131f6 --- /dev/null +++ b/.agents/skills/likec4-dsl/references/model.md @@ -0,0 +1,182 @@ +# Model (LikeC4 DSL) + +The `model` block defines the logical architecture: elements (systems, containers, components, actors) organized in a hierarchy, with relationships between them. Views project from this model. + +**Key rules:** + +- Elements MUST have a kind (from specification) and an identifier unique within their parent. +- Relationships cannot exist directly between parent and child — move them outside the parent element. +- `this` and `it` are aliases for the current element inside a nested relationship. +- Cross-file references require the full FQN (e.g. `cloud.backend.api`); short names are file-scoped. +- `extend FQN { }` adds tags, metadata, and links to an existing element without redefining it. + +## Syntax + +```likec4 +model { + // Elements — top-level elements are global, referenceable by ID anywhere in the project + IDENTIFIER = KIND // no title, no body (title defaults to identifier) + IDENTIFIER = KIND "title" // with title, without body + KIND IDENTIFIER "title" // alternative: kind before identifier + + IDENTIFIER = KIND { + TAGS // optional, must come first if present + PROPERTIES // optional, must come before nested elements/relationships + + IDENTIFIER = KIND "Child Title" { ... } // nested element (unlimited depth) + KIND IDENTIFIER "Child Title" // nested, kind-first syntax + + // Relationships inside element body (current element is implicitly SOURCE) + SOURCE -> TARGET // explicit, both sides named + -> TARGET "title" // implicit source (current element) + -> TARGET "title" { TAGS; PROPERTIES } + <-> TARGET "title" // bidirectional relationship + -[REL_KIND]-> TARGET "title" // typed directed relationship + -[REL_KIND]<-> TARGET "title" // typed bidirectional relationship + .REL_KIND TARGET "title" // alternative typed syntax + SOURCE -> it // TARGET = current element (alias) + this -> TARGET // SOURCE = current element (alias) + } + + // Top-level relationships (outside element body): SOURCE is required + SOURCE -> TARGET "title" + SOURCE <-> TARGET "title" + SOURCE -> TARGET "title" { TAGS; PROPERTIES } + SOURCE -[REL_KIND]-> TARGET + SOURCE -[REL_KIND]<-> TARGET + SOURCE .REL_KIND TARGET + + // Extend existing element (by FQN, from any file) + extend FQN { + TAGS // additional tags to apply + PROPERTIES // only `metadata` and `link` are allowed + NESTED_ELEMENTS | RELATIONSHIPS // additional children or edges + } + + // Extend existing relationship — matcher contract: + // 1) SOURCE and TARGET always required. + // 2) Include KIND when extending a typed relationship. + // 3) Include TITLE when extending a titled relationship. + // Omitting KIND is WRONG (not merely ambiguous) when a typed relation exists. + // Omitting TITLE is WRONG when the matched relation is titled. + extend SOURCE -> TARGET { TAGS; PROPERTIES } // untitled directed relationship + extend SOURCE -> TARGET "title" { TAGS; PROPERTIES } // titled directed relationship + extend SOURCE <-> TARGET { TAGS; PROPERTIES } // untitled bidirectional relationship + extend SOURCE <-> TARGET "title" { TAGS; PROPERTIES } // titled bidirectional relationship + extend SOURCE -[REL_KIND]-> TARGET "title" { TAGS; PROPERTIES } // typed titled relationship + extend SOURCE -[REL_KIND]<-> TARGET "title" { TAGS; PROPERTIES } // typed titled bidirectional +} +``` + +## Full Example + +```likec4 +model { + customer = actor { + title "Customer" + summary "Consumes Cloud Services" + description """ + User with **active** subscription + ... detailed description + """ + } + + cloud = system "Cloud" { + ui = container "Frontend" { + technology "React" + style { shape browser } + metadata { version "1.0.0"; owners ["Name 1", "Name 2"] } + link https://github.com/likec4/likec4 "Repository" + link ../relative/adr1.md + + dashboard = app "Dashboard" { icon tech:react } + } + backend = container "Backend" { + api = service "API" { + #critical + -[sql]-> db "reads/writes" + } + db = database "DB" { + style { icon tech:postgresql; shape storage } + } + } + ui.dashboard -> backend.api { title "calls"; technology "HTTPS" } + } + + customer -> cloud.ui.dashboard "browses" { + metadata { protocol "HTTPS" } + } +} +``` + +## Element Properties + +| Property | Values | +| --------------- | ------------------------------------------------------------------------------------------ | +| **title** | String, single line | +| **description** | String, prefer Markdown; link other elements with `[[fqn]]`. If > 150 chars, add `summary` | +| **summary** | String, max 150 characters | +| **technology** | String, no multi-line | +| **style** | `style { ... }` — see `references/style-tokens-colors.md` | +| **icon** | Shortcut for `style { icon ... }`, takes precedence over the style block | +| **metadata** | `metadata { KEY VALUE }` — key is identifier format; value is string or array of strings | +| **link** | `link URL "Optional title"` — repeatable; URL can be relative to the document | + +Elements have no `navigateTo` property. To set where a click on an element navigates, override it in a view: `include api with { navigateTo detail }`. + +## Relationship Properties + +`title`, `description`, `technology`, `metadata`, `style`, `link`, `navigateTo` + +## Element Links in Markdown + +Markdown values (`'''…'''` / `"""…"""`) can link to elements and views of the current project: + +```likec4 +model { + cloud = system 'Cloud' { + ui = container 'Web UI' + api = container 'API' { + summary ''' + Serves [[cloud.ui]] + ''' + } + ui -> api 'calls' { + description ''' + Requests go to [[cloud.api|the API]], see [[view:index]]. + Not a link: \\[[cloud.api]] and `[[cloud.api]]` + ''' + } + } +} +``` + +- `[[fqn]]` renders the element title, `[[fqn|Label]]` a custom label, `[[view:id]]` the view title. In the diagram, links open the element details or navigate to the view. +- Works in every Markdown value: element, deployment, relationship and view `description`, `summary`, `title`, `technology`, `notes`, metadata values. Plain `'…'` strings and code (inline or fenced) are not scanned. +- FQN is absolute and resolved in the current project only: `[[cloud.backend.api]]`, never `[[api]]` or `[[this]]`. Imported elements are not supported. +- Escaping follows DSL strings: literal `[[` is `\\[[`; in a Markdown table cell write the separator as `\\|`, e.g. `[[cloud.api\\|API]]`. +- Unknown element, unknown view, or unknown `prefix:` is a **warning**: the model still builds and `likec4 validate` stays valid unless `--max-warnings` is given. In the editor, `[[` triggers completion and Go to Definition jumps to the target. +- Prefer `[[fqn]]` over repeating element names in descriptions; link only elements that exist. + +## Extend Patterns + +```likec4 +// Add metadata and a link to an existing element from another file: +extend cloud.backend.api { + metadata { team "platform"; criticality "high" } + link ./adr-002.md "Scaling decision" +} + +// Extend an untyped relationship: +extend cloud.ui.dashboard -> cloud.backend.api { + metadata { sla "99.9%" } +} + +// Extend a typed relationship — include KIND to avoid ambiguous match: +extend cloud.ui.dashboard -[http]-> cloud.backend.api "calls" { + metadata { sla "99.9%" } +} + +// Wrong: if a typed relationship exists, this silently targets the wrong relation +// extend cloud.ui.dashboard -> cloud.backend.api "calls" { ... } +``` diff --git a/.agents/skills/likec4-dsl/references/predicates.md b/.agents/skills/likec4-dsl/references/predicates.md new file mode 100644 index 0000000..5239abf --- /dev/null +++ b/.agents/skills/likec4-dsl/references/predicates.md @@ -0,0 +1,123 @@ +# Predicates + +Predicates are view rules that define what elements and relationships to include or exclude from a view. Predicate types: + +- Wildcard predicate - used to select all elements/relationships, based on view scope +- Element predicates - used to select elements +- Relationship predicates - used to select relationships +- Filter predicates - used to apply filters to element and relationship predicates +- Custom predicates - used to override properties of elements/relationships (can be used with `include` only) + +Syntax: + +``` +EXPRESSION ::= WILDCARD | ELEMENT_EXPRESSION | RELATIONSHIP_EXPRESSION +FILTER_PREDICATE ::= EXPRESSION where FILTER_CONDITIONS +CUSTOMIZE_PREDICATE ::= (EXPRESSION | FILTER_PREDICATE) with { ... } +PREDICATE ::= EXPRESSION | FILTER_PREDICATE | CUSTOMIZE_PREDICATE +``` + +Example: + +```likec4 +include * // WILDCARD +include some.element // ELEMENT_EXPRESSION +include some.from -> some.to // RELATIONSHIP_EXPRESSION +include * where tag is #production // FILTER_PREDICATE +include some.element with { color red } // CUSTOMIZE_PREDICATE on ELEMENT_EXPRESSION +include // CUSTOMIZE_PREDICATE on FILTER_PREDICATE + some.* + where + tag is #production and kind is component + with { + color red + } +``` + +## Expressions + +Expressions inside `exclude` clause match against the accumulated result of previous predicates. Expressions inside `include` clause match against the accumulated result of previous predicates and the model. Element expressions select elements first, and then relationships connected to these elements. Relationship expressions select relationships first, and then elements that are connected by these relationships. + +### Element expression + +- `` - selects element by reference from the current file scope, or globally available if not found in the current file, together with all relationships between this element and accumulated result +- `.` - selects unique child within `` together with all relationships between this child and accumulated result +- `.*` - selects **direct children only** of ``, together with all relationships between these children and accumulated result +- `._` - selects direct children of `` that have relationships with accumulated result +- `.**` - selects **all recursive descendants** of `` that have relationships with accumulated result + +For detailed wildcard edge cases (`*` vs `_` vs `**`), load `references/include-predicates-wildcards.md`. + +### Wildcard expression + +Wildcard expression is a special element expression. + +- If used inside a scoped view, it selects the scoped element, its direct children, and all relationships with them. +- If used inside an unscoped view, it selects top-level elements and all relationships between them. + +### Relationship expression + +Relationship expression uses element expressions as source and target. +If expression matches, predicate adds matched relationships together with matching source and target elements. + +- ` -> ` - relationships from elements selected by `expr1` to elements selected by `expr2` +- ` -> .*` - relationships from elements selected by `expr1` to direct children of `element_ref` +- ` <-> ` - any relationships between elements selected by `expr1` and `expr2` (both directions) +- `-> ` - any relationships, from accumulated result to the elements selected by `expr` +- ` ->` - any relationships, from the elements selected by `expr` to accumulated result +- `-> ->` - any relationships, between the elements selected by `expr` and accumulated result +- `* -> *` - all relationships + +## Filter Conditions + +After expression is evaluated and selected elements/relationships, filter conditions are applied to refine the selection. + +Core filters: + +- Tags: `* where tag is #primary` / `* where tag is not #primary` +- Kind: `* where kind is component` / `* where kind is not component` +- Metadata value: `* where metadata.region is "eu"` +- Missing metadata key: `* where not metadata.owner` +- Arrays: `metadata.regions is "eu"` matches if the array contains `"eu"` +- Booleans: `metadata.critical is true` +- Combine with `and`, `or`, and parentheses + +For relationship expressions: + +- `* -> * where tag is #http` - relationship tag +- `* -> * where metadata.protocol is "grpc"` - relationship metadata +- `* -> * where source.tag is #primary` - source element tag +- `* -> * where target.kind is component` - target element kind +- `* -> * where source.metadata.zone is "public"` - source element metadata +- `* -> * where target.metadata.zone is "restricted"` - target element metadata + +The same filters work in `exclude`, for example: + +```likec4 +exclude * where not metadata.owner +exclude * -> * where metadata.protocol is "http" +exclude * -> * where target.metadata.zone is "restricted" +``` + +Use the same `where` forms with `include`. + +In deployment views, instance tags are cumulative; instance metadata replaces logical metadata as a whole when present, otherwise logical metadata is the fallback. + +## Customize Predicates + +Customize predicates allow you to override properties of selected elements/relationships per view (for example, in different diagrams). +For example: + +```likec4 +include + * -> some.component.* + where + tag is #primary + with { + color red + } +``` + +- Selects all relationships incoming to the children of `some.component.*` +- Filters relationships by tag `#primary` +- Overrides color of selected relationships to red diff --git a/.agents/skills/likec4-dsl/references/relationships-bidirectional.md b/.agents/skills/likec4-dsl/references/relationships-bidirectional.md new file mode 100644 index 0000000..d48c676 --- /dev/null +++ b/.agents/skills/likec4-dsl/references/relationships-bidirectional.md @@ -0,0 +1,176 @@ +# Bidirectional vs. Unidirectional Relationships + +## Key distinction: model relationships vs. view predicates + +LikeC4 supports `<->` in two places with related but different meanings: + +- In `model { ... }`, `<->` declares one semantic bidirectional relationship. +- In `deployment { ... }`, `<->` declares one semantic bidirectional deployment relationship. +- In `views { ... }`, `A <-> B` is a binary predicate that includes relationships between two element sets in either direction. + +## Declaring relationships in the model + +The short snippets in this reference are fragments unless they include their own element declarations. Declare the +referenced elements in your model before using them. + +Use `->` when one element initiates the relationship: + +```likec4 +model { + frontend -> api "REST call" +} +``` + +Use `<->` when both elements actively communicate as one mutual interaction: + +```likec4 +model { + frontend <-> backend "synchronizes" +} +``` + +Bidirectional model relationships render with arrows at both ends by default. Style can still override the rendered arrow tail, but the relationship remains semantically bidirectional for view predicates such as `include -> frontend`. + +## Kinded bidirectional relationships + +Use `-[kind]->` for a directed kinded relationship and `-[kind]<->` for a bidirectional kinded relationship: + +```likec4 +specification { + element component + relationship sync + relationship async +} + +model { + component frontend + component backend + + frontend -[async]-> backend "publishes events" + frontend -[sync]<-> backend "replicates state" +} +``` + +The relationship kind participates in extension matching. When extending a kinded relationship, include the kind in `extend`; also include the title whenever the relationship is titled. + +## Bidirectional deployment relationships + +Deployment relationships use the same notation and semantics: + +```likec4 +specification { + element component + deploymentNode vm + relationship sync +} + +model { + component database +} + +deployment { + vm vm1 { + db = instanceOf database + } + vm vm2 { + db = instanceOf database + } + + vm1.db <-> vm2.db "replicates" + vm1 -[sync]<-> vm2 "heartbeat" +} +``` + +There is no `extend` for deployment relationships, so extension matching does not apply here. + +## Extending bidirectional relationships + +Bidirectional relationship extensions are matched by source, target, kind, title, and bidirectionality. Endpoint order is normalized for bidirectional relationships, so these match the same relationship: + +```likec4 +model { + frontend <-> backend "sync" + + extend backend <-> frontend "sync" { + metadata { + protocol "websocket" + } + } +} +``` + +Directed and bidirectional matchers are not interchangeable: + +```likec4 +model { + frontend -> backend "calls" + frontend <-> backend "sync" + + // Matches only the directed relationship. + extend frontend -> backend "calls" { metadata { protocol "https" } } + + // Matches only the bidirectional relationship. + extend frontend <-> backend "sync" { metadata { protocol "websocket" } } +} +``` + +## When to use one bidirectional relation vs. two directed relations + +Use one `<->` relationship when the model has one mutual interaction: + +```likec4 +model { + primaryDb <-> replicaDb "replicates" +} +``` + +Use two directed relationships when each direction has separate meaning, labels, technology, or lifecycle: + +```likec4 +model { + frontend -> backend "request" + backend -> frontend "callback" +} +``` + +By default LikeC4 may merge opposite relationships into one bidirectional-looking edge. Set `multiple true` in the relationship kind or view style when each direction must render as a separate edge. + +## Relationship predicates in views + +Inside views, `<->` is the "any relationship between both sides" predicate. It is binary and matches relationships between the two sides in either direction: + +```likec4 +views { + view backend-detail of backend { + include * + include -> backend + include backend -> + include -> backend -> + include backend <-> frontend + } +} +``` + +- `-> X` includes incoming relationships to `X`; semantic bidirectional relationships involving `X` are also included. +- `X ->` includes outgoing relationships from `X`. +- `-> X ->` includes both incoming and outgoing relationships of `X`. +- `A <-> B` includes relationships between `A` and `B` in either direction. + +There is no prefix `<-> X` predicate. Use `-> X ->` when you need both incoming and outgoing relationships around one scope. + +## Summary + +| Syntax | Where | Meaning | +| ------------------- | -------------- | ----------------------------------------------------------- | +| `A -> B` | model | Directed relationship: A initiates/calls/sends to B | +| `A <-> B` | model | One bidirectional relationship between A and B | +| `A <-> B` | deployment | One bidirectional deployment relationship between A and B | +| `A -[kind]-> B` | model | Directed relationship with relationship kind | +| `A -[kind]<-> B` | model | Bidirectional relationship with relationship kind | +| `A -> B` + `B -> A` | model | Two separate directed relationships | +| `-> X` | view predicate | Incoming relationships to X, plus bidirectional involving X | +| `X ->` | view predicate | Outgoing relationships from X | +| `-> X ->` | view predicate | Both incoming and outgoing relationships of X | +| `A <-> B` | view predicate | Relationships between A and B in either direction | + +When in doubt, use `->` for request-response patterns where one side initiates. Use `<->` for one mutual interaction, and use two `->` relationships when each direction should be modeled independently. diff --git a/.agents/skills/likec4-dsl/references/specification.md b/.agents/skills/likec4-dsl/references/specification.md new file mode 100644 index 0000000..f971210 --- /dev/null +++ b/.agents/skills/likec4-dsl/references/specification.md @@ -0,0 +1,79 @@ +# Specification (LikeC4 DSL) + +The `specification` block defines all named vocabularies for the project: element kinds, deployment node kinds, relationship kinds, tags, and custom color tokens. Every kind used in `model` or `deployment` blocks must be declared here first. + +**Key rules:** + +- Specification is global across all files in the project. +- Duplicate identifiers (same kind, same tag, etc.) will cause a validation error. +- Multiple `specification` blocks (in one file or across files) are allowed, but not recommended. +- Keep specification in a dedicated file, e.g. `specification.c4` — changes to specification trigger a full project re-parse, so isolating it reduces edit latency on model/view files. + +## Syntax + +```likec4 +specification { + // Define a tag; outside specification it is referenced as #IDENTIFIER + tag IDENTIFIER + + // Define element kind for use in model + element IDENTIFIER { + #tag-1 #tag-2 // tags applied to all elements of this kind + title "default title for this kind" + technology "default tech for this kind" + description "default description for this kind" + notation "legend title for this kind" + style { ... } // see references/style-tokens-colors.md + } + + // Define deployment node kind for use in deployment block + deploymentNode IDENTIFIER { + // same properties and styles as element kind + } + + // Define relationship kind with default properties and styles + relationship IDENTIFIER { + technology "default tech for this relationship kind" + description "default description for this relationship kind" + // default style properties go directly in the body, not in a style block + color amber + line dashed + head diamond + } + + // Define a custom named color token + color IDENTIFIER #FFFFFF // or rgba(255,255,255,1) +} +``` + +## Example + +```likec4 +specification { + element actor { notation "Person"; style { shape person } } + element service { description "Same for all of the kind"; style { shape component } } + element webapp { style { shape browser } } + element queue { style { shape queue; color secondary } } + element database { style { shape storage } } + element system + + relationship async { color amber; line dotted; head diamond; tail vee } + relationship sql { technology "SQL"; line dashed } + + tag deprecated + tag critical + + deploymentNode environment { notation "Environment"; style { color gray } } + deploymentNode zone { notation "Network Zone" } + deploymentNode vm +} +``` + +## Common Mistakes + +| Mistake | Fix | +| -------------------------------- | --------------------------------------------------------------------------------------------- | +| Using a kind not defined here | Define `element KIND { }` or `deploymentNode KIND { }` in specification first | +| Duplicate tag or kind identifier | Each identifier must be unique within its type category | +| `#tag` in specification argument | Tags are defined without `#`; they are used with `#` elsewhere: `tag critical` → `#critical` | +| Raw hex color in style | Define a named color token first: `color my-blue #003366`, then use `color my-blue` in styles | diff --git a/.agents/skills/likec4-dsl/references/style-tokens-colors.md b/.agents/skills/likec4-dsl/references/style-tokens-colors.md new file mode 100644 index 0000000..d6e6288 --- /dev/null +++ b/.agents/skills/likec4-dsl/references/style-tokens-colors.md @@ -0,0 +1,150 @@ +# Style Tokens and Colors (LikeC4 DSL) + +## Canonical color tokens (semantic names) + +LikeC4 provides a curated set of semantic color tokens. **Prefer these named tokens over raw hex colors** for consistency and automatic light/dark theme adaptation. + +### Core semantic token palette + +| Token | Usage | Light appearance | Dark appearance | +| ----------- | ---------------------------------- | ---------------- | --------------- | +| `primary` | Primary brand color, main emphasis | #2F80ED (blue) | lighter blue | +| `secondary` | Secondary accent, de-emphasis | #7C3AED (purple) | lighter purple | +| `muted` | Muted text, disabled states | #9CA3AF (gray) | lighter gray | +| `slate` | Neutral backgrounds, borders | #475569 | lighter slate | +| `blue` | Technical/IT systems | #3B82F6 | lighter blue | +| `indigo` | Data/analytics systems | #6366F1 | lighter indigo | +| `sky` | Cloud, external services | #0EA5E9 | lighter sky | +| `red` | Alerts, critical systems | #EF4444 | lighter red | +| `gray` | Infrastructure, generic | #6B7280 | lighter gray | +| `green` | Success, operational systems | #10B981 | lighter green | +| `amber` | Warnings, async/queue patterns | #F59E0B | lighter amber | + +## Correct usage: semantic tokens + +```likec4 +specification { + element service { + style { color primary } // ✓ Correct: semantic token + } + element critical { + style { color red } // ✓ Correct: semantic token + } + element queue { + style { color amber } // ✓ Correct: semantic token + } +} +``` + +## When hex colors are acceptable + +Hex colors can be used if you define a custom named color in the specification: + +```likec4 +specification { + color my-brand-blue #003366 // Define a custom named token + + element service { + style { color my-brand-blue } // Reference the custom token + } +} +``` + +**Do not use raw hex colors directly in styles without first defining them as named tokens in the specification.** + +## What NOT to do + +```likec4 +style { color #FF5733 } // ✗ Wrong: raw hex color +style { color rgb(255,87,51) } // ✗ Wrong: rgb() notation +style { color "primary" } // ✗ Wrong: quoted string (remove quotes) +``` + +## Style tokens vs. theme colors + +- **Semantic tokens** (`primary`, `secondary`, `muted`, etc.) automatically adapt to light/dark mode. +- **Custom named colors** defined in specification are explicit hex values; use them sparingly for brand colors. +- **Raw hex** in a style property is not recommended — always register in specification first. + +## Icon packs + +Icons use the format `group:name` (e.g. `tech:react`, `aws:lambda`, `azure:app-service`). Five built-in groups are available: + +| Group | Count | Examples | +| ----------- | ----- | ---------------------------------------------- | +| `aws` | ~307 | `aws:lambda`, `aws:s3`, `aws:dynamo-db` | +| `azure` | ~614 | `azure:app-service`, `azure:cosmos-db` | +| `gcp` | ~216 | `gcp:cloud-run`, `gcp:bigquery` | +| `tech` | ~2000 | `tech:react`, `tech:postgresql`, `tech:docker` | +| `bootstrap` | ~2051 | `bootstrap:gear`, `bootstrap:person` | + +To get the full up-to-date list, run `likec4 list-icons` or `likec4 list-icons --group tech` (see `references/cli.md`) + +## Complete style example + +```likec4 +specification { + color brand-orange #FF8C42 // Custom brand color + + element actor { + style { + shape person + color primary + icon tech:user + } + } + + element service { + style { + shape component + color primary + icon tech:gear + } + } + + element critical-service { + style { + shape component + color red // Critical system + icon tech:alert + } + } + + element queue { + style { + shape queue + color amber // Async/queue pattern + } + } +} + +model { + customer = actor { + style { color primary } + } + + api = service { + style { color primary } + } + + alert-responder = critical-service { + style { color red } + } + + job-queue = queue { + style { color amber } + } +} +``` + +## Summary + +| Use case | Correct | Incorrect | +| --------------------- | ------------------------------------------------------------------------------------ | --------------------------- | +| Brand primary color | `style { color primary }` | `style { color "#2F80ED" }` | +| Critical system alert | `style { color red }` | `style { color "#FF0000" }` | +| Async/queue pattern | `style { color amber }` | `style { color "#FFC107" }` | +| Custom brand color | Define in spec + use token: `color my-brand #XXXXXX` then `style { color my-brand }` | Use raw hex in style | +| Theme-aware styling | Use semantic tokens (`primary`, `secondary`, etc.) | Use fixed hex values | + +**Best practice:** Use semantic color tokens for all element and relationship coloring. Define custom named colors in specification only for brand compliance, never use raw hex in styles. diff --git a/.agents/skills/likec4-dsl/references/troubleshooting.md b/.agents/skills/likec4-dsl/references/troubleshooting.md new file mode 100644 index 0000000..b781acc --- /dev/null +++ b/.agents/skills/likec4-dsl/references/troubleshooting.md @@ -0,0 +1,101 @@ +# Common Mistakes & Debugging (LikeC4 DSL) + +Load this file when encountering validation errors, unexpected rendering, or when an eval answer is failing. + +## Syntax Errors + +| Error | Cause | Fix | +| ---------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------ | +| "Identifier PAYMENT.API not found" | Dots in identifier name | Use `payment-api` not `payment.api`; dots are FQN separators only | +| "Unknown kind SERVICE" | Kind not in specification | Define `element service { ... }` in the specification block | +| "Duplicate FQN cloud.backend" | Element ID repeated under the same parent | Rename one element; each sibling must have a unique identifier | +| "Expected property TYPE after include *" | Malformed filter predicate | Use `include * where kind is component`, not `include * component` | +| "Invalid relationship kind async-cache" | Kind not found in specification | Define `relationship async-cache { ... }` in specification first | + +## Model & Hierarchy + +| Error | Cause | Fix | +| ------------------------------------------------ | ------------------------------------------------------------------ | ----------------------------------------------------------------------------- | +| Element shows but relationships don't render | Relationship references FQN incorrectly | Use exact FQN matching the model hierarchy | +| "Can't define relationship from parent to child" | Direct parent-child relationships are forbidden | Move the relationship outside the parent element or use implicit notation | +| Child element not visible from other files | Referencing by short name instead of FQN | Import or use full FQN: `cloud.backend.api` | +| Extend block adds duplicate tags | Tags stack on merge | Use consistent tag names; duplicates are not deduplicated automatically | +| Warning `Element 'api' not found` on `[[api]]` | Markdown link target is not an absolute FQN of the current project | Use the full FQN: `[[cloud.backend.api]]`; imported elements cannot be linked | +| Warning `Unknown link namespace 'deployment'` | Only `view:` is a known prefix in `[[…]]` | Use `[[view:id]]` or an element FQN | +| `[[cloud.api]]` shows as raw text | Plain `'…'` string, or inside inline/fenced code | Use a Markdown value `'''…'''`; move the link out of code | + +Labelled link breaks a Markdown table cell: a bare pipe splits GFM table cells, so inside tables write the separator as `\\|`, e.g. `[[cloud.api\\|API]]`. + +## View Predicates + +| Error | Cause | Fix | +| ------------------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------- | +| `include *` shows grandchildren | Confusing `*` with `**` | `*` = direct children only; use `**` for recursive descent | +| Relationships disappear in scoped view | Neighbors not explicitly included | Add `include -> scope` or `include <-> scope` to pull in inbound/outbound sources | +| "WHERE predicate not matching any elements" | Tag or kind name is case-sensitive | Use exact case: `#Critical` ≠ `#critical` | +| Element included but styled differently | Global vs. local style conflict | Local view styles override global; audit style rules order in the view block | + +## Deployment + +| Error | Cause | Fix | +| --------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------ | +| `instanceOf` doesn't resolve | Instance refers to wrong FQN | Use the exact logical model FQN, not the deployment node identifier | +| "Undefined DEPLOYMENT_KIND" | Kind referenced but not in specification | Define `deploymentNode vm { ... }` in specification | +| Deployment relationship inherits unexpectedly | Logical model edges are inherited automatically | Suppress inherited relationships explicitly in the deployment view if unwanted | + +## Dynamic Views + +| Error | Cause | Fix | +| ------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `Nested parallel blocks are not allowed` | Nested `parallel { parallel { ... } }` (or `par`) | Flatten: put all concurrent steps in a single `parallel { }` block. Other flow blocks (`opt`/`loop`/`try`/`alt`) may nest freely | +| `"loop" can not be used as an alternative branch` | Non-branch block (`loop`/`opt`/`parallel`/`try`) placed directly inside `alt` | `alt` accepts only `when`/`if`/`else` branches; nest the block inside a branch | +| `"when" alternative branch must be inside "alt"` | `when`/`if`/`else` used at top level or inside a non-`alt` block | Wrap the branches in an `alt { ... }` block | +| `catch`/`finally` rejected | `catch` without a preceding `try`, or `catch` after `finally` | Keep order `try` → `catch?` → `finally?` | +| Response arrows (`<-`) show wrong direction | Chained `<-` steps are not supported | After `a -> b -> c`, write one `<-` step per hop: `b <- c`, then `a <- b`. `<-` cannot be chained (`a <- b <- c` is a parse error) | +| `navigateTo` link doesn't work | Target view name does not exist | Ensure the target view name exists in the same project; from a dynamic step it must be another `dynamic view` | +| `variant sequence` is ignored | Wrong keyword or spelling | Use exact: `variant sequence` (not `type`, `mode`, or `sequence` alone) | + +## Validation & Import + +| Most Common | Solution | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `validate` reports config not found | Ensure `likec4.config.json` exists in the project root directory | +| `Imported project not found` | `import` takes another project's `name` (from its `likec4.config`), not a file path: `import { x } from 'other-project'`. Files of the same project need no import; reference elements by full FQN | +| Symbol from import invisible in model | Symbol must be public (defined at top level); nested elements need FQN | +| Large error count in project but your file is clean | Use `likec4 validate --json --no-layout --file ` and check `filteredErrors`/`filteredFiles`; text mode can also print diagnostics from other workspace files before the filtered summary | + +## Performance & Large Models + +| Symptom | Cause | Action | +| ------------------------------ | ---------------------------------------------------------- | ------------------------------------------------------------------ | +| Validation takes >30s | Specification changes trigger full project re-parse | Keep specification in a separate file; avoid editing it frequently | +| Export generates a partial PNG | Layout engine timeout on complex view | Split large views with `navigateTo` or reduce the element count | +| IDE responsiveness slow | Too many open files or large specification in working file | Split into `spec.c4` + `model.c4` + `views.c4` | + +## Debugging Workflow + +When encountering errors, follow these steps in order: + +1. **Run validation with `--file` and check `filteredErrors`:** + ```bash + likec4 validate --json --no-layout --file + ``` + If `filteredErrors` = 0 but `totalErrors` > 0, your file is clean; the error is in an upstream file. + +2. **Check FQN integrity:** Find the identifier from the error message and verify it matches the model hierarchy exactly. + +3. **Isolate predicate issues:** Copy the failing `include`/`exclude` rule into a new minimal test view to confirm predicate semantics in isolation. + +4. **Validate specification first:** Comment out `model`, `deployment`, and `views`; validate the `specification` block alone. If it passes, uncomment the next block and repeat. + +5. **Use `extend` strategically:** When enriching elements across files, use `extend FQN { }` rather than re-declaring — re-declarations cause duplicate FQN errors. + +## Skill Best Practices + +1. **Always start with project structure understanding.** Use the `understand-project-structure` skill before making changes to an unfamiliar project. +2. **For relationship ambiguity, always include KIND + TITLE.** When source/target/kind match multiple relationships, include title in the matcher to avoid silently targeting the wrong one. +3. **Response discipline on strict prompts.** Output one final answer first (no alternatives) when the prompt says "exact", "minimal", or "paste-ready". +4. **Validate after each file edit.** Always run `likec4 validate --json --no-layout --file ` after changes; use `--file` to focus on your changes only. +5. **Cross-file references use FQN.** Never assume short names resolve across file boundaries; they don't. +6. **Keep specification stable.** Changes invalidate the entire model parse; prefer a separate `spec.c4` file and minimize changes to it. +7. **Test wildcard semantics locally.** When unsure whether `*` or `**` is correct, create a minimal test view to confirm behavior before committing. diff --git a/.agents/skills/likec4-dsl/references/views.md b/.agents/skills/likec4-dsl/references/views.md new file mode 100644 index 0000000..a464a11 --- /dev/null +++ b/.agents/skills/likec4-dsl/references/views.md @@ -0,0 +1,329 @@ +# Views + +Three types of views are supported: + +1. Element views - projections of the model, based on predicates +2. Dynamic views - step-by-step sequence of interactions between elements +3. Deployment views - projections of the deployment model, based on predicates + +View IDENTIFIER must be unique within the project, available across all files. Duplicate identifiers will result in a validation error. +There is special view - `index`, if not defined, it will be automatically created to include all top-level elements. + +Syntax: + +```likec4 +views { + + LOCAL_STYLE_RULES // optional style rules, applied to all views in this block + + // element view + view IDENTIFIER { + TAGS // optional tags, must come first if present, before any properties + PROPERTIES // optional, but must come before any view rules + ELEMENT_VIEW_RULES + } + // element view can also be scoped to a specific element, explained below + view IDENTIFIER of ELEMENT_ID { + TAGS + PROPERTIES + ELEMENT_VIEW_RULES + } + // dynamic views + dynamic view IDENTIFIER { + TAGS + PROPERTIES + DYNAMIC_VIEW_RULES + } + // deployment views + deployment view IDENTIFIER { + TAGS + PROPERTIES + DEPLOYMENT_VIEW_RULES + } +} +``` + +## Element View Rules + +Syntax: + +```likec4 +include PREDICATE, PREDICATE, ... +exclude PREDICATE, PREDICATE, ... +style ELEMENT_EXPRESSION, ... { + // Apply style properties to elements matching the expressions +} +global style STYLE_GROUP_IDENTIFIER +autoLayout TopBottom|BottomTop|LeftRight|RightLeft [rankSep] [nodeSep] [spline|ortho] +``` + +See [Predicates](./predicates.md) for more information on predicates and expressions. + +**Important:** + +- Rules order matters, as every next rule applies on top of the previous, accumulating result. +- `exclude` only removes elements that were included by previous rules. +- `style` rules override previously applied styles. + - Style cascade (each override the previous): Spec defaults → element properties → local styles → view-level styles → customized predicates + +## Edge routing + +To draw relationships with right-angle bends, set `routing ortho` in a view: + +```likec4 +view { + routing ortho + include * +} +``` + +Use `spline` for curved relationships or `ortho` for horizontal and vertical segments. The default is `spline`. Routing applies to element, dynamic, and deployment views. Sequence diagrams ignore this setting. + +LikeC4 uses the first value defined in this precedence order: + +1. The view's `routing` property. +2. The last `autoLayout` rule's routing parameter. +3. The project default, `styles.defaults.view.routing`. +4. The built-in default, `spline`. + +If both the view property and `autoLayout` set routing, the property takes precedence and the language server reports a warning. Changing routing alone preserves saved node positions and control points without invalidating the layout. + +Graphviz can route relationships through unrelated group boxes or place parallel routes close together. To reduce crowding, show fewer relationships or edit individual connections. Increasing `autoLayout` spacing gives nodes more room, but doesn't guarantee separation between routes. + +Edited edges that share a straight segment move onto separate tracks where space at their endpoints permits. Unedited Graphviz routes keep their positions. Automatic label placement avoids nodes, other labels, and routes where space permits; dense views can still contain overlaps. + +## Dynamic View Rules + +Syntax: + +```text +STEP ::= + SOURCE -> TARGET [LABEL] // forward message + | SOURCE <- TARGET [LABEL] // backward message + [{ + RELATIONSHIP_PROPERTIES + RELATIONSHIP_STYLE_PROPERTIES + }] + + +DYNAMIC_VIEW_STYLE_RULE ::= + style EXPRESSION, ... { + // Apply style properties to elements matching the expressions + } + + +DYNAMIC_VIEW_RULES ::= + STEP + STEP + DYNAMIC_VIEW_STYLE_RULE + ... +``` + +## Local Style Rules + +Styles placed inside `views {}` but outside any `view {}` apply to all views in that block: + +```likec4 +views { + // This style applies to ALL views in this block + style * { color green } + + view view1 { + include * // All elements are green + } + view index { + include * + style backend { color red } // All elements are green, except backend which is red + } +} +``` + +## Global Style Groups + +Reusable style groups defined in `global { ... }`: + +Syntax: + +```likec4 +global { + styleGroup GROUP_IDENTIFIER { + style EXPRESSION { ... } + style EXPRESSION { ... } + } +} + +views { + view index { + global style GROUP_IDENTIFIER + } +} +``` + +```likec4 +global { + styleGroup brandColors { + style * { color primary } + style element.tag = #deprecated { color muted; opacity 30% } + style element.tag = #critical { color red; border dashed } + } +} + +views { + view index { + include * + global style brandColors // Apply the style group + } +} +``` + +Style groups can contain multiple `style` rules. They are applied in the order defined within the group. When used in a view, global styles sit between local styles and view-level styles in the cascade. + +# Dynamic Views — Detailed Reference + +## Steps + +Each step represents an interaction between two elements: + +```likec4 +// Forward step +customer -> frontend "opens app" + +// Backward step (response/return flow) +frontend <- backend "returns data" + +// Step with full properties +customer -> frontend "places order" { + title "Customer places an order" // Override step label + description "Detailed description" + technology "HTTPS" + notes ''' + Additional notes displayed in sidebar. + Supports **Markdown** formatting. + ''' + color red + navigateTo order-detail // Link to another dynamic view +} +``` + +Step properties: `title`, `description`, `technology`, `notes`, `navigateTo`, all relationship properties. + +### Chained Steps + +Steps can be chained to reduce repetition: + +```likec4 +customer + -> frontend "opens" // Read as "customer opens frontend" + -> backend "requests" // Read as "frontend requests backend" + -> database "queries" // Read as "backend queries database" +backend <- database "responds" // Read as "database responds to backend" +``` + +Each arrow in the chain creates a separate step. The target of the previous step becomes the source of the next. A chain can continue with `->` or a relationship-kind arrow such as `-[kind]->`. A chain cannot continue with `<-`, so write a response as its own step. + +## Parallel Steps + +Use `parallel` (or `par`) blocks for concurrent interactions: + +```likec4 +dynamic view flow { + frontend -> backend "requests data" + + parallel { + backend -> cache "checks cache" + backend -> database "queries DB" + backend -> external-api "fetches enrichment" + } + + backend -> frontend "returns aggregated data" +} +``` + +Parallel blocks can be mixed with sequential steps, but cannot be nested inside another `parallel`/`par` block (`Nested parallel blocks are not allowed`). + +## Variants + +| Variant | Rendering | Use case | +| ------------------- | ----------------------------- | ---------------------------------- | +| `diagram` (default) | Animated box-and-line diagram | General flow visualization | +| `sequence` | UML sequence diagram | API call sequences, protocol flows | + +### Using `variant sequence` + +The `variant sequence` keyword renders the dynamic view as a UML sequence diagram. This is especially useful for API call sequences and protocol flows. + +**Key syntax points:** + +- Use `variant sequence` at the start of the dynamic view block +- Use `->` for forward/call direction +- Use `<-` for backward/return direction (not `->` with different semantics) +- Sequence diagrams work best with leaf elements (not containers) + +```likec4 +dynamic view api-sequence { + variant sequence + + client -> gateway "POST /orders" + gateway -> auth "validate token" + auth <- gateway "200 OK" + gateway -> orders "create order" + orders -> db "INSERT" + orders <- db "order_id" + gateway <- orders "201 Created" + client <- gateway "201 Created" +} +``` + +**Common mistake:** Using `->` for returns instead of `<-`. The arrow direction indicates message flow: + +- `a -> b` means "a sends to b" (request/call) +- `a <- b` means "b sends to a" (response/return) + +```likec4 +// ❌ WRONG - using -> for returns +dynamic view wrong { + variant sequence + client -> gateway "request" + gateway -> client "response" // This renders incorrectly! +} + +// ✅ CORRECT - using <- for returns +dynamic view correct { + variant sequence + client -> gateway "request" + client <- gateway "response" // Proper return flow +} +``` + +## Include in Dynamic Views + +Dynamic views support element-view predicates for context elements that do not participate in steps. Predicates do not generate or filter the step sequence itself. + +```likec4 +dynamic view flow { + customer -> frontend "opens" + frontend -> backend "requests" + + // Add parent containers as visual context + include cloud with { + color muted + opacity 10% + } + include amazon +} +``` + +## Styling in Dynamic Views + +Same styling rules as element views: + +```likec4 +dynamic view flow { + customer -> frontend "opens" + frontend -> backend "requests" + + style customer { color green } + style * { size sm } + style frontend { color muted } +} +``` diff --git a/.agents/skills/likec4-gh-pr-triage/SKILL.md b/.agents/skills/likec4-gh-pr-triage/SKILL.md new file mode 100644 index 0000000..b840df7 --- /dev/null +++ b/.agents/skills/likec4-gh-pr-triage/SKILL.md @@ -0,0 +1,111 @@ +--- +name: likec4-gh-pr-triage +description: Use when checking LikeC4 GitHub issues, mentions, PR status, review comments, CodeRabbit feedback, reviewer requests, or terse status follow-ups with `gh`. +--- + +# LikeC4 GitHub PR Triage + +Use `gh` and local git as the source of truth. Browser automation is a fallback only when explicitly requested or `gh` cannot answer. + +## Ground rules + +- Re-check live state before reporting readiness, mergeability, reviews, or comments. +- Keep `status` replies compact: PR number, checks, review state, blocker. +- Do not post comments, mark ready, request reviewers, close issues, or merge unless the user explicitly asks. +- Preserve unrelated local files. In mixed worktrees, stage explicit paths only. + +## Identify the active PR + +```bash +git status -sb +git branch --show-current +gh pr view "$(git branch --show-current)" --repo likec4/likec4 \ + --json number,title,url,headRefName,baseRefName,state,isDraft,reviewDecision,mergeStateStatus,reviewRequests +``` + +If the current branch has no PR, ask whether to inspect a specific PR number or create one. + +## Check assigned issues and mentions + +Set the target login from the request or live GitHub context. Do not hard-code personal usernames. + +```bash +GITHUB_LOGIN='REPLACE_WITH_GITHUB_LOGIN' +REPO=likec4/likec4 + +gh api -X GET search/issues --paginate \ + --raw-field q="repo:${REPO} is:issue assignee:${GITHUB_LOGIN}" \ + --raw-field per_page=100 +gh api -X GET search/issues --paginate \ + --raw-field q="repo:${REPO} is:issue mentions:${GITHUB_LOGIN}" \ + --raw-field per_page=100 +gh api -X GET search/issues --paginate \ + --raw-field q="repo:${REPO} is:issue is:open mentions:${GITHUB_LOGIN}" \ + --raw-field per_page=100 +``` + +Use `gh api 'notifications?...'` only for the authenticated user; notifications do not answer arbitrary-login mention searches. + +For exact context: + +```bash +gh issue view ISSUE --repo likec4/likec4 --comments +gh api repos/likec4/likec4/issues/ISSUE/timeline --paginate +``` + +Search issue bodies, comments, and timeline entries for exact mention locations. + +## Check PR health + +```bash +gh pr view PR --repo likec4/likec4 \ + --json number,title,url,isDraft,reviewDecision,mergeStateStatus,reviewRequests,statusCheckRollup,latestReviews +gh pr checks PR --repo likec4/likec4 --watch=false +``` + +`REVIEW_REQUIRED` means human review still blocks. `BLOCKED` with green checks usually means required review or branch protection. + +## Check review comments + +```bash +gh pr view PR --repo likec4/likec4 --comments +gh api repos/likec4/likec4/pulls/PR/comments --paginate +``` + +Filter by reviewer or bot only after resolving the current login from PR comments or review metadata. + +If counts disagree with GitHub UI, inspect review threads with GraphQL; resolved/outdated line comments may not show in simple comment views. + +## Common requested actions + +- Trigger CodeRabbit only when requested by posting a PR comment such as `@coderabbitai review`. +- Mark ready only when requested and after checking branch state and validation. +- Add reviewers only when requested, for example: + +```bash +gh pr edit PR --repo likec4/likec4 --add-reviewer REVIEWER_LOGIN +``` + +## Reporting format + +For status: + +```text +PR #NNNN: checks pass/fail, review state, merge state. Open blocker: ... +``` + +For comment triage: + +```text +Open comments: +- reviewer: actionable summary, file/path if known +No action needed: +- reviewer: reason +``` + +For mention triage: + +```text +LOGIN is mentioned in issue(s); remain open. +- #NNNN — mentioned in <body/comment/timeline>; current action/blocker; URL +``` diff --git a/.agents/skills/likec4-issue-repro/SKILL.md b/.agents/skills/likec4-issue-repro/SKILL.md new file mode 100644 index 0000000..a4a0db5 --- /dev/null +++ b/.agents/skills/likec4-issue-repro/SKILL.md @@ -0,0 +1,78 @@ +--- +name: likec4-issue-repro +description: Use when reproducing, diagnosing, or verifying a reported LikeC4 bug from a GitHub issue or user report. Trigger for tasks asking whether an issue can be reproduced, requesting a minimal repro, checking bug reports, or turning a reproduction into a focused regression test. +--- + +# LikeC4 Issue Reproduction + +Reproduce with the smallest fixture that exercises the reported behavior, then give a direct yes/no with evidence. + +## Ground rules + +- Prefer live GitHub issue data from `gh issue view ISSUE --repo likec4/likec4 --comments`. +- Keep fixtures outside unrelated source changes, usually in a temp directory or a focused test fixture. +- Do not churn dependencies. If `pnpm` fails unexpectedly, check disk space before reinstalling. +- If reproduction is blocked, report the exact blocker and the next command that would prove it. + +## Route the issue + +Use the report symptoms to choose the likely code area: + +| Symptom | Start here | +| ----------------------------------------------------------- | ----------------------------------------------------------------- | +| `likec4 gen`, exporters, `--outdir`, generated files | `packages/likec4/src/cli/codegen` | +| `likec4.config`, project discovery, include paths, excludes | `packages/language-server/src/workspace` | +| DSL parsing, validation, completions, imports | `packages/language-server` | +| computed views, predicates, relationships | `packages/core` | +| diagram rendering, browser behavior, embedded UI | `packages/diagram`, `packages/likec4-spa`, `packages/vite-plugin` | + +If the route is codegen or project config, also use the corresponding LikeC4 repo skill. + +## Reproduction workflow + +1. Capture the exact report: + +```bash +gh issue view ISSUE --repo likec4/likec4 --comments +``` + +2. Build a minimal fixture: + +- one `likec4.config.*` if project scope matters +- one `specification` block with only required kinds +- one `model` block with the smallest failing element or relationship shape +- one `views` block only if rendering or codegen needs it + +3. Run the narrowest command that exercises the failure. + +Examples: + +```bash +pnpm --filter @likec4/language-server test -- ProjectsManager.spec.ts +pnpm --filter likec4 test -- codegen +pnpm --filter @likec4/core test -- relationship +pnpm --filter @likec4/diagram test -- path/to/spec +``` + +4. If reproducible, add or update a focused regression test before changing implementation. + +5. Re-run the focused test, then expand only as risk requires: + +```bash +pnpm --filter PACKAGE test -- SPEC_OR_PATTERN +pnpm --filter PACKAGE typecheck +pnpm exec dprint check CHANGED_FILES +git diff --check +``` + +## Evidence to report + +Use this format: + +```text +Reproduced: yes/no. +Fixture: path or short description. +Failing command: exact command and relevant failure. +Fix verification: exact command that passed. +Remaining risk: anything not covered. +``` diff --git a/.agents/skills/likec4-project-config-workflow/SKILL.md b/.agents/skills/likec4-project-config-workflow/SKILL.md new file mode 100644 index 0000000..80f5982 --- /dev/null +++ b/.agents/skills/likec4-project-config-workflow/SKILL.md @@ -0,0 +1,66 @@ +--- +name: likec4-project-config-workflow +description: Use when changing or reviewing LikeC4 project discovery, `likec4.config` handling, include paths, excludes, workspace folders, document ownership, or `ProjectsManager` behavior. Trigger for bugs about multi-project workflows, excluded files, included external folders, default project ownership, or Windows path matching. +--- + +# LikeC4 Project Config Workflow + +Project ownership and exclusion rules are subtle. Preserve the distinction between "which project owns this document" and "is this document included for this project". + +## Ground rules + +- Start in `packages/language-server/src/workspace/ProjectsManager.ts`. +- Check callers in `WorkspaceManager.ts`, `LangiumDocuments.ts`, and model parsing before changing semantics. +- Prefer focused `ProjectsManager.spec.ts` coverage before implementation. +- Keep Windows path behavior covered when touching path normalization or folder matching. + +## Concepts to preserve + +- A LikeC4 project is defined by config files such as `.likec4rc` or `likec4.config.*`. +- The nearest config normally determines project ownership. +- `include.paths` adds source roots for a project; paths are resolved relative to the project folder. +- Project-level `exclude` affects that project, but another project may still include the same physical file. +- Workspace-level excludes take precedence over project-level inclusion. +- Default excludes such as `node_modules` still apply where no explicit project rule overrides them. + +Important distinction: + +- `ownerProjectId(document)` answers ownership. +- `isExcluded(projectId, document)` answers exclusion for a specific project. +- `isExcluded(document)` answers whether the document should be skipped in the effective owner context. + +## Regression matrix + +Add or update tests for the relevant rows: + +| Case | What to assert | +| -------------------------------------------------------- | -------------------------------------------------------------- | +| nested projects | nearest config wins | +| sibling projects with similar prefixes | `qwe` does not own `qwe-qwe` | +| include path outside project folder | included document participates in the including project | +| document excluded by project A but included by project B | project-specific exclusion differs by project | +| shared include path | deterministic owner and no duplicate document processing | +| include path removed on reload | stale include ownership disappears | +| workspace startup scanning | include-path documents are loaded before project docs use them | +| workspace exclude | excluded even if project config includes it | +| Windows paths | drive and backslash paths match expected project | + +## Focused commands + +```bash +pnpm --filter @likec4/language-server test -- ProjectsManager.spec.ts +pnpm --filter @likec4/language-server typecheck +pnpm exec dprint check packages/language-server/src/workspace +git diff --check +``` + +If a unit test manually adds the document, also test the workspace-startup path. Manual document injection can pass while real include-path scanning still drops the file. + +## PR evidence + +Report: + +- the reproduced project/include/exclude scenario +- the focused test name +- whether Windows path behavior was covered +- any intentionally unchanged behavior diff --git a/.agents/skills/project-manifest-reference/SKILL.md b/.agents/skills/project-manifest-reference/SKILL.md new file mode 100644 index 0000000..d02ca06 --- /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/<name>/SKILL.md` (ADR 0010); mirrored to `/skills/<id>/<name>/` 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 `<path>/**/*.md` at each channel's ref (default branch when no `channels`) | +| `wiki` | `rootPage`, `readmeAsIndex`, `order`, `exclude` | Shallow-clones `<repo>.wiki.git`; `_Sidebar.md` orders pages, `_Footer.md` is skipped. Single channel only | +| `readme` | none | Publishes `README.md` alone as `/docs/<id>/`. 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/<id>/…`; `prerelease` is + served at `/docs/<id>/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-architecture-testing/SKILL.md b/.agents/skills/purview-architecture-testing/SKILL.md index 0619225..55d0ca8 100644 --- a/.agents/skills/purview-architecture-testing/SKILL.md +++ b/.agents/skills/purview-architecture-testing/SKILL.md @@ -9,6 +9,7 @@ license: MIT metadata: author: Purview-Dev version: "2.0" + repository: purview-dev/engineering-playbook --- # Purview architecture testing @@ -83,7 +84,7 @@ debugging the package. Then add a test that the documentation matches the policy, so the table in the wiki is falsifiable too. Full worked example: -[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/foundations/Project-Responsibilities.md). ## 3. Read project files, and assemblies @@ -180,4 +181,4 @@ project classified so every dependency rule still applies to it. Prefer a waiver - `purview-architecture` — what the projects are for, and how they are named - `purview-buildsdk-architecture-tests` — the package's own skill, authoritative on its API -- [Architecture as Tests](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Architecture-As-Tests.md) +- [Architecture as Tests](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/foundations/Architecture-As-Tests.md) diff --git a/.agents/skills/purview-architecture/SKILL.md b/.agents/skills/purview-architecture/SKILL.md index c04f3d6..0c43596 100644 --- a/.agents/skills/purview-architecture/SKILL.md +++ b/.agents/skills/purview-architecture/SKILL.md @@ -10,6 +10,7 @@ license: MIT metadata: author: Purview-Dev version: "1.0" + repository: purview-dev/engineering-playbook --- # Purview architecture @@ -173,4 +174,4 @@ small telemetry — it does not get none. See `purview-telemetry`. - `purview-telemetry` — instrumentation at every scale - `purview-persistence` — storage and GUID-key guidance, only when persistence is real - [Engineering Principles](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Engineering-Principles.md) -- [Adopting This At Your Scale](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Adopting-This-At-Your-Scale.md) +- [Adopting This At Your Scale](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/governance/Adopting-This-At-Your-Scale.md) diff --git a/.agents/skills/purview-architecture/references/contract-strategy.md b/.agents/skills/purview-architecture/references/contract-strategy.md index e281a41..62b5a17 100644 --- a/.agents/skills/purview-architecture/references/contract-strategy.md +++ b/.agents/skills/purview-architecture/references/contract-strategy.md @@ -230,7 +230,7 @@ combines" stays a review obligation, because a dependency matrix cannot express ## See also -- [Contracts and Data Exchange](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Contracts-And-Data-Exchange.md) +- [Contracts and Data Exchange](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/data/Contracts-And-Data-Exchange.md) — `Create` and `Hydrate`, and the schema as the source of truth for a wire shape - [zodsharp](https://github.com/purview-dev/zodsharp) — authoritative on `[ZodSchema]` and JSON Schema export diff --git a/.agents/skills/purview-architecture/references/project-responsibilities.md b/.agents/skills/purview-architecture/references/project-responsibilities.md index d74d2cf..dc4e67c 100644 --- a/.agents/skills/purview-architecture/references/project-responsibilities.md +++ b/.agents/skills/purview-architecture/references/project-responsibilities.md @@ -3,7 +3,7 @@ The catalogue in brief. The authoritative definition is [`.config/architecture-policy.json`](https://github.com/purview-dev/engineering-playbook/blob/main/.config/architecture-policy.json), and the full page — with diagrams — is -[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/foundations/Project-Responsibilities.md). Do not restate either here or in another skill; link to them. ## The catalogue diff --git a/.agents/skills/purview-buildsdk-adoption/SKILL.md b/.agents/skills/purview-buildsdk-adoption/SKILL.md index b1f222c..07be90a 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<Set>`, 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 <project> -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 <UsePackageJsonVersion>Strict</UsePackageJsonVersion> ``` -`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 @@ -100,9 +125,56 @@ silently gets no runner, no coverage, no `InternalsVisibleTo` and no category. ## 5. Agent skills are a separate, explicit step -The SDK does not install agent skills, and nothing it does during a build writes to an agent -directory. It reads which skills each referenced package ships and recommends the command that -installs them; the skills CLI does the installing. See `purview-buildsdk-skill-installation`. +Nothing the SDK does during a restore or a build touches agent skills. Installing, updating and +cleaning them up are separate targets a person runs by name — `InstallPurviewSkills`, +`UpdatePurviewSkills`, `CheckPurviewSkills`, `FixPurviewSkills` — and the ones that change the +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-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 <project> -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** — `<PurviewTargetFrameworkSet>Supported</PurviewTargetFrameworkSet>` 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<Set>` 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 diff --git a/.agents/skills/purview-buildsdk-configuration/SKILL.md b/.agents/skills/purview-buildsdk-configuration/SKILL.md index d82768b..767a81a 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 `<Import>`: + +| 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** — `<TargetFramework>net10.0</TargetFramework>` for one target, + `<TargetFrameworks>net10.0;net11.0</TargetFrameworks>` 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** — `<PurviewTargetFrameworkSet>Supported</PurviewTargetFrameworkSet>` 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<Set>` (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 | @@ -41,15 +81,20 @@ in `Directory.Build.props` unless the setting is deliberately project-specific. | `TestingFramework` | `TUnit` | `TUnit`, `Xunit` or `None` | | `SubstituteFramework` | `TUnitMocks` | `TUnitMocks`, `NSubstitute` or `None` | | `TestDataFramework` | `Bogus` | `Bogus` or `None` | -| `TargetFramework` | `net10.0` | `netstandard2.0` when `IsRoslynComponent=true` | +| `TargetFramework` | `net10.0;net11.0` | `netstandard2.0` when `IsRoslynComponent=true` | | `IsPackable` | `false` | Opts a project into the packable defaults | | `UsePackageJsonVersion` | `true` | `true`, `false` or `Strict` — version from `package.json` | | `ExcludePurviewTelemetry` | `false` | Drops the telemetry source generator | -| `DisableSourceLink` | `false` | Drops SourceLink from packable projects | +| `DisableSourceLink` | `false` | Stops the SDK adding the SourceLink provider package to any project | | `EnableAssemblyNameGeneration` | `true` | `AssemblyName`/`PackageId` follow the resolved `RootNamespace` | | `PurviewBrowserSafe` | *(unset)* | On a class library a WebAssembly client downloads: trim, AOT and browser-unsupported APIs fail the build | | `IsBffProject` | name ends `Bff` | Classifies a BFF host; `false` opts out | | `PurviewBrowserSafeReferenceCheckAsError` | `false` | Makes `PRBLZ0001` (a client reference that is not browser-safe) an error | +| `MemorySafetyRules` | `0` | C# 15 memory-safety tier on `net11.0`+: `0` off (default), `1` preview syntax, `2` updated rules (`updated-memory-safety-rules`, opt-in — an experimental compiler feature) | +| `PurviewRuntimeAsync` | *(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`; 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 — @@ -74,10 +119,22 @@ dotnet msbuild <project> -getProperty:TestingType -getProperty:TargetProjectName ## Compiler-visible properties -The SDK exports its properties to analyzers and generators as `build_property.<PropertyName>`. 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.<PropertyName>`: + +```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 `<Description>`) 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 @@ -89,11 +146,18 @@ is the SDK's answer, and it stays correct when the rules change. The exported se ## Repository bootstrap -The SDK bootstraps two repository-wide files from the package: `.editorconfig` and `global.json`. +The SDK bootstraps three repository-wide files from the package: `.editorconfig`, `global.json` and +`Directory.Build.rsp`. - `PurviewRepoBootstrapMode` — `IfMissing` (default), `Always`, `WarnOnDrift`, `Never` - `DisableAutoCopySdkFiles` — the master off switch - `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). 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 @@ -105,11 +169,42 @@ the repository; that is gone, along with `EnableAgentFolderInPackage`, `AgentPac and the `PurviewAgentFolder*` properties. Skills have exactly one installer, the skills CLI — see `purview-buildsdk-skill-installation`. -## Guidance when changing SDK configuration +The skill targets read their settings when they run, so these can go in `Directory.Build.props` or +on the command line, and a repository-wide choice belongs in `Directory.Build.props`: -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. +| Property | Default | Controls | +| -- | -- | -- | +| `PurviewSkillsRunner` | `bun` (`bun x skills`) | Which package runner launches the skills CLI | +| `PurviewSkillsAgents` | empty — the CLI auto-detects | Agents to install to; when set, the only agent directories `CheckPurviewSkills` allows | +| `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-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 <project> -getProperty:<Name>` 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 1fb1f81..8efd3db 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 (`<project name minus NamespacePrefix, dots removed>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` | 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 `<Warning>` 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<Set>` 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 | @@ -36,6 +48,19 @@ consuming project supplies nothing. | `DisablePurviewTestContextRuleSet` | `false` | Makes test and shared-testing projects enforce the production API-surface rules too | | `PurviewPolicyExemptNoWarn` | *(derived)* | The SDK-injected `NoWarn` entries `ValidatePurviewStylePolicy` accepts | +## .NET 11 features and ServerShared + +| Property | Default | Behaviour | +| -- | -- | -- | +| `MemorySafetyRules` | `0` | C# 15 memory-safety tier on `net11.0`+ targets: `0` off (default), `1` preview pointer syntax (sets `LangVersion=preview` when none was chosen), `2` `updated-memory-safety-rules`. Tier `2` is opt-in — the updated rules are an experimental compiler feature. Unknown values fail with `PRCFG0001`. Never touches `AllowUnsafeBlocks` | +| `PurviewRuntimeAsync` | *(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`/`*.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 | + ## Testing | Property | Default | Behaviour | @@ -47,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 @@ -63,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. @@ -78,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 @@ -90,11 +122,11 @@ forces SourceLink's CI-mode dirty-repository checks. | `PurviewAutoBlazorClientReference` | `true` | A host named `<Name>` references its `<Name>.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. @@ -104,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 | @@ -119,6 +151,9 @@ reference was judged browser-safe when it cannot say so itself. | `RepositoryEditorConfigFilePath` | *(derived)* | Overrides the destination `.editorconfig` | | `BootstrapGlobalJsonToRepoRoot` | `true` | | | `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`; unset, a WebAssembly client writes `-mt:false` | | `PurviewBuildSdkVersionForGlobalJson` | restored package version, else the version it was packed as | | | `PurviewRepoBootstrapCopyRetries` | `3` | | | `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | | @@ -136,11 +171,23 @@ nothing else was left for the mirror to deliver. Covered in full by `purview-buildsdk-skill-installation`: `PurviewSkillsRunner`, `PurviewSkillsRunnerCandidates`, `PurviewSkillsCommand`, `PurviewSkillsPackage`, `PurviewSkillsAgents`, `PurviewSkillsSource`, `PurviewSkillsInstallRoot`, `PurviewSkillsRepoRoot`, -`EnablePurviewSkillRecommendations`, `PurviewSkillRecommendationsInCI`. +`PurviewSkillsDryRun`, and the `PurviewSkillsAgentDirectory` item group. ## Compiler-visible properties -Exported to analyzers and generators as `build_property.<PropertyName>`: +The SDK lists these as `CompilerVisibleProperty` items, so Roslyn analyzers and source generators read +them as `build_property.<PropertyName>`: + +```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 `<Description>`) 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` @@ -148,7 +195,11 @@ Exported to analyzers and generators as `build_property.<PropertyName>`: - `ExcludePurviewTelemetry`, `ExcludeMSTelemetryExtension` - `EnableAssemblyNameGeneration`, `DisableAutoInternalsVisibleTo`, `DisableGenerateAssemblyInfoClass`, `DisableGenerateAssemblyIdentifierClass`, `AssemblyIdentifierClassName` -- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject` +- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject`, + `IsServerSharedProject`, `IsClientSideProject` +- `PurviewAutoServerSharedReference`, `DisablePurviewServerSharedReferenceCheck`, + `PurviewServerSharedReferenceCheckAsError` +- `MemorySafetyRules`, `PurviewRuntimeAsync`, `PurviewMultiThreadedBuild` - `TestingType`, `TargetProjectName` - `IsContainerProject`, `IsSdkProject`, `SdkProjectName`, `IsWebProject`, `IsWebSdkProject`, `IsWorkerSdkProject`, `IsAspireHostProject`, `IsCLIProject` @@ -163,6 +214,6 @@ Exported to analyzers and generators as `build_property.<PropertyName>`: - `PurviewSuppressCopyRetryWarnings` - `PurviewSkillsSourceFolder`, `PurviewSkillsAgents`, `PurviewSkillsInstallRoot`, `PurviewRepositoryRootMarkers`, - `PurviewSkillsRunner` + `PurviewSkillsRunner`, `PurviewSkillsDryRun` - `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile`, `AutoGeneratedAssemblyIdentifierFile` 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 0000000..aafa623 --- /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 +<!-- Directory.Build.props, above the <Import> --> +<PropertyGroup> + <SourceLinkPackageName>Microsoft.SourceLink.AzureRepos.Git</SourceLinkPackageName> +</PropertyGroup> +``` + +### 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 + <ItemGroup> + <SourceLinkAzureDevOpsServerGitHost Include="tfs.example.com" VirtualDirectory="tfs" /> + </ItemGroup> + ``` + + `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 `<repository url="…" commit="…" />` 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#<commit>`. 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 0000000..9661abc --- /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/<name>/SKILL.md`, `skills/<name>/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 `<vendor>-<product>-<topic>`, 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/<name>/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] - <date>`, 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 '<each renamed or removed name>' -- '*.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 0000000..3da13b7 --- /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 '<Name\b' -- '*.props' '*.targets'`. The default is the unconditioned or `'$(Name)' == ''`-guarded assignment. For a real answer, evaluate one: `dotnet msbuild <project> -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 `<Target Name="...">`, 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 <recipe>` 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 <path>`, 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 <project> -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/<name>/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 0000000..a4b7c53 --- /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) + <Deep-Linked-Page>.md any page the site catalogue deep-links (docsPage) + <topic>/ + <Page-Name>.md + <topic>/ + ... +``` + +- **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/<project>/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 +<!-- Page order for the documentation site: every page once, grouped by section, in reading order. + Section labels match the `## ` headings in Home.md. --> + +- [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 +# <Product> Wiki + +<one paragraph: what the product is> + +## 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 0000000..0ac76c2 --- /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 <repository>. 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 0000000..8cb38ff --- /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 <major|minor|patch>`. +// +// 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 <major|minor|patch> [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 0000000..646d444 --- /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 0000000..9507263 --- /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 0000000..6156f34 --- /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 0000000..1d73bbe --- /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 0000000..2154876 --- /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 0000000..244e44c --- /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 0000000..0d33902 --- /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 '#<ref>' to pin it) +# +# just skills-add purview-dev/engineering-playbook +[doc("Install skills from another repository through the skills CLI (append '#<ref>' 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 0000000..d813497 --- /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 0000000..09dbb37 --- /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 0000000..5c850f5 --- /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 <owner/repo[#ref]>` | `InstallPurviewSkills` with `PurviewSkillsSource` | `skills add <source> --skill "*"` | yes | +| `skills-install` | `InstallPurviewSkills` | `skills experimental_install` — restore `skills-lock.json` | yes | +| `skills-update` | `UpdatePurviewSkills;FixPurviewSkills` | `skills add <source> --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 +<PropertyGroup> + <!-- Defaults: bun ('bun x skills'), and no agents (the skills CLI auto-detects). --> + <PurviewSkillsRunner>bun</PurviewSkillsRunner> + <!-- Optional. Setting it makes skills-check/skills-fix enforce that only these agents' + skill directories exist. --> + <PurviewSkillsAgents>universal</PurviewSkillsAgents> +</PropertyGroup> +``` + +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 0000000..0011124 --- /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 `<path of an imported file>.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 + <AcmeKitStrict Condition="'$(AcmeKitStrict)' == ''">false</AcmeKitStrict> + ``` + + 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 `<Import>`, 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 `<Description>`: + + ```xml + <ItemGroup> + <CompilerVisibleProperty Include="AcmeKitStrict"> + <Description>When true, the Acme analyzers report ACME0001 as an error.</Description> + </CompilerVisibleProperty> + </ItemGroup> + ``` + + 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: <why>.` — "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 <project> -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 + <AcmeKitStrictMode Condition="'$(AcmeKitStrictMode)' == '' AND '$(AcmeKitStrict)' != ''">$(AcmeKitStrict)</AcmeKitStrictMode> + <AcmeKitStrictMode Condition="'$(AcmeKitStrictMode)' == ''">false</AcmeKitStrictMode> + ``` + +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 +<!-- the package's .csproj --> +<PropertyGroup> + <EnablePurviewPropertySchemaValidation>true</EnablePurviewPropertySchemaValidation> + <!-- exports the package does not own; MSBuild already describes them --> + <PurviewPropertySchemaIgnoredProperties>RootNamespace;Version</PurviewPropertySchemaIgnoredProperties> +</PropertyGroup> +``` + +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 `<file>.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 +(<https://marketplace.visualstudio.com/items?itemName=mhutch.msbuildeditor>). 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 0000000..1f4e7b3 --- /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 +<https://github.com/mhutch/MonoDevelop.MSBuildEditor/raw/main/MonoDevelop.MSBuild/Schemas/buildschema.json>. +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: <why>.` | +| `type` | An intrinsic type name, an inline type, or `{ "$ref": "#/types/<name>" }`. | +| `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 8573ca4..a893896 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, 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 @@ -25,7 +25,8 @@ dotnet msbuild <project> -getProperty:PurviewLogicalProjectName -getProperty:Roo The values worth inspecting first: `MSBuildProjectName`, `NamespacePrefix`, `PurviewLogicalProjectName`, `RootNamespace`, `TestingType`, `TargetProjectName`, `SdkProjectName`, -`IsTestProject`, `IsSharedProject`, `IsSharedTestingProject`, `IsContainerProject`, `IsCLIProject`, +`IsTestProject`, `IsSharedProject`, `IsServerSharedProject`, `IsClientSideProject`, +`IsSharedTestingProject`, `IsContainerProject`, `IsCLIProject`, `IsWebSdkProject`, `IsAspireHostProject`, `IsBlazorWebAssemblyProject`, `IsRazorClassLibraryProject`, `IsBlazorHostProject`, `IsBffProject`. @@ -52,11 +53,16 @@ a non-test project grants access to `{AssemblyName}.{TestType}Tests` for each na suffix outside it still builds, runs and gets a category — it just cannot see `internal` members. The 53 names are in [references/test-types.md](references/test-types.md). -### Shared and shared-testing projects +### Shared, shared-testing and ServerShared projects -Recognised by **exact name**, never by substring. The two lists are in +Recognised by **exact name**, never by substring. The three lists are in [references/detection.md](references/detection.md). Derived: `IsSharedProject`, -`IsSharedTestingProject`. +`IsSharedTestingProject`, `IsServerSharedProject`. + +`ServerShared` is separate from `Shared` for a reason: a `Shared` project is referenced by almost +everything, including code that ends up in a browser, so server-only helpers get their own name and +are referenced automatically only by a project that cannot be downloaded. `IsClientSideProject` is the +classification that decides it. ### SDK and content based @@ -87,13 +93,28 @@ dotnet msbuild <host> -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 @@ -104,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 @@ -121,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 `<Analyzer>` 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 | | -- | -- | @@ -131,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), @@ -149,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 <project> -getProperty:<Flag>` 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 c65d153..8c2e6f9 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 '<path>' ...` line; +- every other project leaves that file alone while `<path>` 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: `<PurviewMultiThreadedBuild>false</PurviewMultiThreadedBuild>`, 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: - `<SupportedPlatform Include="browser" />`, 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 85fde09..155e345 100644 --- a/.agents/skills/purview-buildsdk-project-detection/references/detection.md +++ b/.agents/skills/purview-buildsdk-project-detection/references/detection.md @@ -5,6 +5,20 @@ `Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, `SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers` +## ServerShared project names (exact) + +`ServerShared`, `ServerSharedFramework`, `ServerSharedInfrastructure`, `ServerSharedInfra`, +`ServerSharedUtilities`, `ServerSharedUtils`, `ServerSharedLibrary`, `ServerSharedLib`, +`ServerSharedHelpers` + +A `ServerShared` project is referenced automatically by every non-client-side project, and never by a +WebAssembly client, a `PurviewBrowserSafe` library, a Razor class library, a project named with a +client-side segment (`*.Client`, `*.ClientShared`, `*.Browser`, `*.Wasm`, `*.WebAssembly`, +`*.Components`, or exactly `Components`/`ClientShared`), or a `Shared`/`SharedTesting` project. +`IsClientSideProject` overrides the classification. `PRSHR0001` reports a client-side reference and +`PRSHR0002` a `Shared` reference; `PurviewBrowserSafe="true"` on the reference records an accepted +decision. + ## Shared testing project names (exact) `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, @@ -15,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 @@ -62,9 +77,10 @@ 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` | -| Blazor WebAssembly client | not packable, no container defaults, no automatic `Shared` reference, the browser-safe reference check - see [blazor.md](blazor.md) | +| 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 `<Name>.Client` - see [blazor.md](blazor.md) | | BFF | no packages, no OpenAPI interceptor unless it exposes an API, `PDS0008`/`PDS0009` - see [blazor.md](blazor.md) | | `PurviewBrowserSafe` library | `IsAotCompatible`, `SupportedPlatform browser`, trim/AOT/`CA1416` as errors - see [blazor.md](blazor.md) | @@ -77,7 +93,7 @@ Two ship with the code-fix assembly, both serving the `PDS0002` convention: targeting several receiver types. It produces one `<ReceiverType>Extensions` class per receiver (a generic `this TBuilder where TBuilder : IHostApplicationBuilder` receiver becomes `HostApplicationBuilderExtensions`) and places each under `Extensions/<receiver namespace>/`. -- **Move extensions class to conventional location** — offered on a misplaced single-receiver - extensions class. It re-paths the file to `Extensions/<receiver namespace>/`, 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/<receiver namespace>/`, 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 6c19c3d..e339bd1 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 ce1fd29..15f90c0 100644 --- a/.agents/skills/purview-buildsdk-repository-review/SKILL.md +++ b/.agents/skills/purview-buildsdk-repository-review/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-buildsdk-repository-review -description: Review a repository that uses Purview.BuildSdk for naming, placement and test-structure alignment, and report what the SDK can and cannot do automatically as a result. Use when asked to audit, rationalise or health-check a repo against the SDK's conventions, when onboarding an existing codebase onto Purview.BuildSdk, or when deciding whether a layout quirk is worth changing. +description: Review a repository that uses Purview.BuildSdk for naming, placement, test-structure and agent-skill-management alignment, and report what the SDK can and cannot do automatically as a result. Use when asked to audit, rationalise or health-check a repo against the SDK's conventions, when onboarding an existing codebase onto Purview.BuildSdk, or when deciding whether a layout quirk is worth changing. license: MIT metadata: package: Purview.BuildSdk @@ -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 @@ -89,7 +96,25 @@ go stale: is not any more. - A package the SDK already adds, restated in a project file, is maintenance cost with no effect. -## 6. Explain consequences, not just mismatches +## 6. Check how agent skills are managed + +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: 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 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. + +## 7. Explain consequences, not just mismatches For each finding, name the specific automatic behaviour that is lost: `RootNamespace`, `AssemblyName`, `PackageId`, `TargetProjectName`, the automatic target-project reference, the 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 0000000..728a0a6 --- /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: <why>.`; 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 `<Description>`, 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` `<Description>`, 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 `## [<package.json version>] - <date>`: `### 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 `## <Feature>` section to `README.md`, and — when +it is configuration — a `references/<feature>.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` `<Description>` ("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 `<Import>` | +| 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<Set>` (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 `<Description>`) 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 66c20a3..545eb9f 100644 --- a/.agents/skills/purview-buildsdk-skill-installation/SKILL.md +++ b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md @@ -1,137 +1,142 @@ --- name: purview-buildsdk-skill-installation -description: Install, list and diagnose the agent skills a Purview.BuildSdk package ships. Use for the InstallPurviewSkills, ListPurviewSkills and RecommendPurviewSkills MSBuild targets, for any PRSKL0001-PRSKL0014 diagnostic, for choosing a package runner (npx, pnpm dlx, bunx, yarn dlx, deno), for skills-lock.json, or when a package ships skills but no agent can see them. +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 *authoring* skills, use `purview-buildsdk-skill-tests`; for `just` recipes that run these +targets, `purview-buildsdk-justfile`. ## The one thing to understand first -**The SDK writes nothing into an agent directory.** A package *ships* `.agents/skills/<name>/`, 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 <project> -t:ListPurviewSkills # diagnostics only, installs nothing -dotnet msbuild <project> -t:InstallPurviewSkills # delegates to the skills CLI +dotnet msbuild <solution> -t:InstallPurviewSkills -p:PurviewSkillsSource=purview-dev/build-sdk # add a source +dotnet msbuild <solution> -t:InstallPurviewSkills # restore skills-lock.json (a fresh clone) +dotnet msbuild <solution> -t:UpdatePurviewSkills # re-add every lockfile source at its latest +dotnet msbuild <solution> -t:CheckPurviewSkills # only the PurviewSkillsAgents directories exist +dotnet msbuild <solution> -t:FixPurviewSkills # remove the CLI's links from any other agent directory ``` -Neither target has `BeforeTargets` or `AfterTargets`. If skills are appearing or changing on their -own, something has been wired up that should not be. - -## How a skill is resolved, and why the version is always right +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 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. -There is deliberately **no custom manifest**. A package that ships skills already carries both -halves of the mapping, and `ResolvePurviewSkillSources` reads them: +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. -- `.agents/skills/<name>/` in the restored package says *which* skills it ships. -- The nuspec's `<repository url="..." commit="..." />`, written by SourceLink for every package this - SDK builds, says which git repository and which **exact commit** produced them. +## Installing -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. +| 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#<ref>` | the same, pinned to a commit, tag or branch | +| `-t:InstallPurviewSkills` | `skills experimental_install` — restore exactly what `skills-lock.json` records | -## Installing from an explicit source +The caller owns the ref: append `#<ref>` 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. -For a skill you want but have no `PackageReference` for: +## Updating ```bash -dotnet msbuild <project> -t:InstallPurviewSkills -p:PurviewSkillsSource=purview-dev/build-sdk +dotnet msbuild <solution> -t:UpdatePurviewSkills -p:PurviewSkillsDryRun=true # see the commands first +dotnet msbuild <solution> -t:UpdatePurviewSkills ``` -The caller owns the ref here — append `#<ref>` 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=<path>` | -| `PRSKL0004` | No skills CLI and no package runner found | Install Node, or set `PurviewSkillsCommand` | -| `PRSKL0005` | An authored skill is malformed | See `purview-buildsdk-skill-tests` | -| `PRSKL0010` | A skill a referenced package ships is missing | Warning. `npx skills add ...` | -| `PRSKL0011` | An installed skill is pinned to a different ref than the referenced package | Warning. Install the package's own commit | -| `PRSKL0012` | A skill cannot be verified | Message | -| `PRSKL0013` | An orphaned lockfile entry | Message | -| `PRSKL0014` | Recorded in the lockfile but never restored | Message | +`UpdatePurviewSkills` re-adds every unique source recorded in `skills-lock.json` with +`skills add "<source>" --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`. -## Recommendations run during an ordinary build +## Choosing agents -This is the **one** skill feature that does. `RecommendPurviewSkillsOnBuild` runs after `Build` and -delegates to `RecommendPurviewSkills`, which can also be invoked by name: +`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 `Directory.Build.props` +(`<PurviewSkillsAgents>universal</PurviewSkillsAgents>`), or on a command line with the separator +escaped, because `dotnet msbuild -p:` itself splits on `,` and `;`: ```bash -dotnet msbuild <project> -t:RecommendPurviewSkills +dotnet msbuild <solution> -t:UpdatePurviewSkills -p:PurviewSkillsAgents=claude-code%2Ccodex ``` -`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. +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. -Four things keep it quiet, and all of them are load-bearing: +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 +allowed — it is the CLI's canonical store — and the authored `skills/` folder is never touched. +`FixPurviewSkills` removes links (symlinks, junctions) from those directories, never their targets, +and then any folder left empty; a real file is kept and reported, because it is not provably the +CLI's. The agent-to-directory map is the `PurviewSkillsAgentDirectory` item group, taken from the +CLI's own agent table; an agent it does not know is `PRSKL0016`, fixed by adding an item: -- Design-time builds, CI builds, non-default TFM passes and - `EnablePurviewSkillRecommendations=false` are skipped outright. - `PurviewSkillRecommendationsInCI=true` re-enables CI. -- A fingerprint stamp at `<repo root>/.purview/skill-recommendations.cache` means a given state is - reported once per repository, not once per project and again on every rebuild. The explicit target - ignores the stamp. -- Missing and drifted are **warnings** (fixable in one command); unverifiable and orphaned are - **messages**. No amount of skill advice can break a build using `TreatWarningsAsErrors`. -- A correctly installed set produces no output at all. +```xml +<PurviewSkillsAgentDirectory Include=".new-agent/skills" Agents="new-agent" /> +``` -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. +With `PurviewSkillsAgents` empty, both targets say so and do nothing: every agent directory is then a +legitimate destination. -### Drift never recommends `npx skills update` +## Diagnostics -`update` installs the *newest* published skill, which is only correct when the newest skill happens -to describe the referenced package version. `PRSKL0011` names the referenced package's own commit in -a pinned `npx skills add` instead. +| Code | Meaning | Fix | +| -- | -- | -- | +| `PRSKL0003` | No repository root resolved | Pass `-p:PurviewSkillsInstallRoot=<path>`, 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` | +| `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 | +| `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 | + +`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 The CLI is published to npm, and every runner spells "run this npm package" differently. -`PurviewSkillsRunner` selects one, defaulting to `npx` because npx ships with Node itself. +`PurviewSkillsRunner` selects one, defaulting to `bun` (`bun x skills`). | Runner | Invocation | | -- | -- | +| `bun` *(default)* | `bun x skills@1` | | `npx` | `npx -y skills@1` | | `bunx` | `bunx skills@1` | -| `bun` | `bun x skills@1` | | `pnpm` | `pnpm dlx skills@1` | | `yarn` | `yarn dlx skills@1` — Yarn 2+ only | | `deno` | `deno run -A npm:skills@1` | +| `skills` | `skills` — already on `PATH`, no download | -An unknown runner falls through to `<runner> $(PurviewSkillsPackage)`, so it still works. +An unknown runner falls through to `<runner> $(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 @@ -146,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. @@ -155,12 +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 `npx skills experimental_install`. - -`PurviewSkillsAgents` (default `*`) narrows the target agents. - -The lockfile is read **directly** rather than by parsing `npx skills list --json`, because that -listing omits the `ref` — and the ref is the whole compatibility question. Reading a file also needs -no Node tooling, which is what keeps recommendations working on a machine that can build .NET but -has no Node. There is deliberately no competing lockfile. +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 e90b695..64cd1c4 100644 --- a/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md +++ b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md @@ -43,13 +43,16 @@ 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 equally why the build hook's CI and design-time gating works at all. +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`. ## The managed port `Purview.Skills.Testing` ships `SkillRepositoryRoot`, a managed port of the same walk with the same default markers, so a test that validates a repository's skills resolves the same root the build -installs them into. The two are kept in step by `SkillRepositoryRootTests`, including an assertion -on the default marker list itself — both are defaults nobody sets, so nothing else would notice them -drifting apart. +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 4043ac4..e6def7d 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/<name>/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 69d4ee4..03d9bbf 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 5a5a7d5..c7a7995 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 <project> --treenode-filter "/*/*/*/*[Category=Unit]" +dotnet test --project <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-component-compatibility/SKILL.md b/.agents/skills/purview-component-compatibility/SKILL.md new file mode 100644 index 0000000..b017681 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/SKILL.md @@ -0,0 +1,116 @@ +--- +name: purview-component-compatibility +description: >- + Use when developing, reviewing or releasing a published component - a NuGet package, or an npm + package - and choosing which dependency version to declare, when a consumer reports a version + conflict or an unexpected upgrade, or when a component's supported target frameworks change. Applies + the golden rule that a published component declares the LOWEST compatible version rather than the + latest, walks the Purview component hierarchy to find each floor, and verifies it by building + against it. Not for applications: they take the latest compatible version instead. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" + repository: purview-dev/engineering-playbook +--- + +# Purview component compatibility + +A published component makes a promise with every version it declares: *this is the least I require of +you*. Raise that number and every consumer is pulled up with you, whether or not they wanted it. + +**The golden rule: declare the lowest version that compiles, passes your tests, and is supported on +every target framework you claim.** The version you happen to have installed is never the answer, and +neither is the latest. + +## Which direction? + +| What you are publishing | Declare | Why | +| -- | -- | -- | +| an application, a test suite, a tool — it runs, and nobody installs it | the **latest** compatible version | you own the runtime, and a version you skip is a fix you did not take | +| a component — a NuGet or npm package somebody else installs | the **lowest** compatible version | dependency resolution takes the highest minimum in the graph, so your floor becomes theirs | + +This skill is about the second row. If the repository only ever runs, stop here and take the latest. + +## Why a floor matters + +NuGet and npm resolve a graph to the **highest** minimum among all constraints. If a package declares +`Testcontainers` 4.16 because 4.16 is what its author had installed, every consumer is forced onto 4.16, +including the one whose own constraint is 4.0 and whose runtime is pinned to it. The consumer cannot +refuse an upgrade the package never needed to ask for, and the symptom appears in *their* build, not +yours. + +## The workflow + +### 1. Name the component and its frameworks + +The floor is per target framework, not per package. Write down every `TargetFramework` the component +publishes; a multi-targeting component has a different floor on each, and the one that matters is the +oldest framework still supported, not the newest one you build against. + +### 2. Read the hierarchy, do not remember it + +The dependencies — and their minimums — are in the published `.nuspec`, not in anyone's memory: + +```bash +node scripts/dependencies.mjs Purview.Containers +node scripts/dependencies.mjs Purview.Architecture.Testing --tfm net11.0 +node scripts/dependencies.mjs Purview.Containers --walk +``` + +A bare version is a **minimum**; a bracketed one (`[1.2.3]`, `[1.0,2.0)`) is a range. The script prints +the distinction, because it is the whole rule: a minimum you raise is a minimum every consumer inherits. + +`references/purview-components.md` records which Purview package depends on which, and which target +frameworks each family supports. Where the two disagree the registry wins — that file is the map, the +`.nuspec` is the territory. + +### 3. Fix the floor, per framework + +For each dependency, find the oldest version that: + +- exposes every API the component calls (the compiler decides this, not the changelog); +- passes the component's own tests and its consumer-verification suite; +- is supported on every framework in that dependency group. + +Then declare that version. Not `latest`, not floating (`1.*`), and never the version that resolved on +your machine by accident. + +### 4. Prove the floor + +A floor nobody builds against is a guess. Add a CI leg that restores the component with every dependency +held at its declared floor and runs the suite — the ordinary leg keeps resolving the latest, which is +what a consumer will actually get. `references/lowest-compatible.md` has the mechanics. + +### 5. Keep the ceiling visible, and the floor written down + +A component's README carries a badge for the current release and a sentence naming the floor it +supports. Badges are generated from the registry, so they cannot go stale; a version written into prose +is wrong within one release. + +## Pitfalls that produce a floor nobody meant + +| Pitfall | What it looks like | The fix | +| -- | -- | -- | +| Declaring what is installed | `dotnet add package X` writes the latest; right for an application, wrong for a component | edit the version down, then prove it in step 4 | +| Transitive uplift | a dependency pulls in a newer transient package than your floor allows | override it deliberately — the persistence sample does this for a security-patched package — and comment why | +| One framework's floor | a `net10.0-windows` group needs a higher floor than `net10.0` | read each group; never average them | +| Prerelease in the graph | a family that ships prereleases depends on them at the same version | pin the family to one property so the floor moves together | +| Analyzer and source-generator packages | compile-time only, so they rarely constrain a consumer | still declare a real floor; they appear in the graph and in transitive audits | +| Floating versions | `1.*` resolves differently on every restore | banned: a floor is a number | + +## Checks before you answer + +- Is each declared version the lowest that passes, or the one that happened to be installed? +- Does every declared target framework have a floor that compiles on that framework? +- Was the floor proved by a build, or only by your machine? +- Does any page state a version number? It should be a badge or a link to the registry. +- If a floor was raised, is that a breaking change the version number admits to? + +## See also + +`references/lowest-compatible.md` for the CI mechanics and the override patterns, and +`references/purview-components.md` for the Purview hierarchy. The rule itself, and the application-side +half of it, is stated in the Engineering Playbook's Dependency Versions page; the +`purview-engineering-principles` skill summarises it as one of the eleven conventions. + diff --git a/.agents/skills/purview-component-compatibility/references/lowest-compatible.md b/.agents/skills/purview-component-compatibility/references/lowest-compatible.md new file mode 100644 index 0000000..ccdb3b6 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/references/lowest-compatible.md @@ -0,0 +1,93 @@ +# Proving the floor + +The rule is "declare the lowest compatible version". This file is the mechanics: how to find the floor, +how to prove it in a build, and the patterns that keep it from silently rising. + +## What "compatible" means, precisely + +A version is a valid floor when all three hold: + +1. **It compiles against every API the component uses.** The compiler decides this, not a changelog. If + you cannot say which member forced the version up, you have not found the floor. +2. **The component's own tests pass against it**, including the consumer-verification suite, which + builds a throwaway consumer rather than the component's own test project. +3. **It is supported on every target framework in that dependency group.** A floor is per group, and a + multi-targeting component can have several. + +## Finding it + +```bash +dotnet list package --outdated # what you declared against what exists +dotnet list package --include-transitive # what actually resolved +dotnet nuget why <project> <package> # which path pulled a version in +``` + +Then work downward: lower the declared version, build, and read the compiler. The first version that +compiles *and* passes is the floor. Do not stop at the first failure and pick the version above it — +that is how a floor ends up one minor above what the code needs. + +## Making the floor a build leg + +Keep one property per family (so a family cannot be bumped halfway) and make each conditional on a floor +switch. Nothing here is NuGet-specific magic: it is MSBuild, and central package management already +gives you the single place to put it. + +```xml +<PropertyGroup Label="Purview Versions"> + <PurviewTelemetryVersion Condition="'$(DependencyFloor)' != 'true'">LATEST</PurviewTelemetryVersion> + <PurviewTelemetryVersion Condition="'$(DependencyFloor)' == 'true'">FLOOR</PurviewTelemetryVersion> +</PropertyGroup> +``` + +```bash +dotnet restore -p:DependencyFloor=true +dotnet build -p:DependencyFloor=true --no-restore +dotnet test -p:DependencyFloor=true --no-build +``` + +`LATEST` and `FLOOR` are literals in the file; the point is that they are two properties, not two files. +Run the ordinary leg at `LATEST` and a second, slower leg at `FLOOR`, and give the floor leg its own CI +job so a failure names the floor rather than the build. + +Where a component multi-targets, add the framework dimension and run the pair per framework: + +```bash +for tfm in net8.0 net9.0 net10.0; do + dotnet build -p:DependencyFloor=true -f "$tfm" +done +``` + +## Transitive pins + +A floor can be raised by something you never declared. Transitive pinning puts those versions where you +can see them: + +```xml +<PropertyGroup> + <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled> +</PropertyGroup> +``` + +Then an override is a deliberate line with a reason beside it — the persistence sample in this +repository does exactly that for a security-patched package the SQL client drags in. An override without +a comment is indistinguishable from an accident, and the next person deletes it. + +## The ceiling, and the API surface + +- The floor leg proves the **bottom**; the ordinary leg proves the **top**. Both, or you know neither. +- An API-compatibility check against the previous release catches the other direction of the same + mistake: a floor can stay put while the public surface grows a member that only the newest dependency + provides. `Microsoft.DotNet.ApiCompat.Task` wires that into the build; `Microsoft.DotNet.ApiCompat.Tool` + runs it from a pipeline. +- A prerelease dependency is a floor with an expiry: it cannot be consumed by anyone who has not opted + into prereleases. Say so in the package's README rather than letting the error message explain it. + +## What not to do + +| Temptation | Why it is wrong | +| -- | -- | +| `dotnet add package X` and commit | it writes the latest; correct for an application, wrong for a component | +| floating versions (`1.*`) | resolves differently on every restore, so the floor is nobody's decision | +| "it restores on my machine" | that proves one graph, on one date, with one SDK | +| raising a floor in a patch release | the consumer's build changes without their version changing | +| one floor leg for all frameworks | the framework with the lowest floor is the one nobody tested | diff --git a/.agents/skills/purview-component-compatibility/references/purview-components.md b/.agents/skills/purview-component-compatibility/references/purview-components.md new file mode 100644 index 0000000..6e13b65 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/references/purview-components.md @@ -0,0 +1,60 @@ +# The Purview component hierarchy + +Which Purview package depends on which, and which target frameworks each family supports, so a harvest +or a floor decision starts from the shape rather than from a memory of it. + +Every row was read from the registry with `scripts/dependencies.mjs`, which reads the published +`.nuspec`. **The registry is the authority; this file is the map.** Re-run the script before acting on +any row — a family that multi-targets changes its floors on every release. + +## The families + +| Family | Packages | Target frameworks | Depends on | +| -- | -- | -- | -- | +| Build SDK | `Purview.BuildSdk` | `netstandard2.0` (an MSBuild SDK) | nothing | +| Test-support rule sets | `Purview.Skills.Testing`, `Purview.Architecture.Testing`, `Purview.Architecture.Testing.Cli` | `net10.0`; `Architecture.Testing` also `net11.0` | `TUnit.Core`, `TUnit.Assertions`, `JsonSchema.Net` | +| Telemetry | `Purview.Telemetry.SourceGenerator` | `netstandard2.0` (an analyzer) | nothing | +| Containers | `Purview.Containers` + `.Core`, `.Docker`, `.Wsl`, `.MsSql`, `.PostgreSql` | `net10.0`; `.Wsl` adds `net10.0-windows10.0.19041` | its own family at the same version, `Microsoft.Extensions.Telemetry.Abstractions`, `Testcontainers` | +| Results | `Purview.Results`, `.AspNetCore`, `.ZodSharp`, `.ZodSharp.AspNetCore` | `net10.0`, `net11.0` | nothing; the adapters depend on the core and on ZodSharp | +| ZodSharp | `Purview.ZodSharp`, `.AspNetCore`, `.SystemTextJson`, `.NewtonsoftJson` | `net9.0`, `net10.0`, `net11.0` | `Microsoft.Extensions.Options`, `Microsoft.Extensions.DependencyInjection.Abstractions` | +| Value Objects | `Purview.ValueObjects` | `net8.0`, `net9.0`, `net10.0`, `net11.0` | nothing | +| Source Generator Framework | `Purview.SourceGeneratorFramework`, `.Testing`, `.Testing.TUnit` | `netstandard2.0` | Roslyn (`Microsoft.CodeAnalysis.CSharp`), `System.Collections.Immutable`, `System.Reflection.Metadata` | +| Aspire ResourceKit | `Purview.Aspire.ResourceKit` | `net8.0`, `net9.0`, `net10.0` | `Aspire.Hosting`, OpenTelemetry exporter and hosting, `Microsoft.Extensions.*` | +| Astroc4 | `@purview-dev/astroc4` (npm, not NuGet) | an Astro/Starlight plugin | `likec4`, `@astrojs/starlight`, `astro` as peers | + +## Reading the shape + +Three patterns are worth internalising, because each one changes what "the floor" means: + +- **A family ships at one version.** `Purview.Containers.Core` is required at the umbrella's own version, + and `Purview.Results.ZodSharp` requires `Purview.Results` and `Purview.ZodSharp` at their current ones. + Declare the family with one property, so its floor moves as a unit. +- **An analyzer has no consumer-side floor worth arguing about.** `Purview.BuildSdk`, + `Purview.Telemetry.SourceGenerator` and `Purview.SourceGeneratorFramework` target `netstandard2.0` and do + their work at compile time. They still appear in a transitive audit, so they still get a real version. +- **A multi-targeting component has a different floor per framework**, and ZodSharp shows it most + clearly: the `net11.0` group declares older `Microsoft.Extensions.*` minimums than the `net10.0` group, + because the lower framework carries the newer requirements. Reading one group and applying it to + another is how a floor is set wrong in the direction that breaks consumers. + +## What the script prints + +```text +Purview.Containers 1.0.0-prerelease.11 + net10.0: Purview.Containers.Core >= 1.0.0-prerelease.11 + net10.0: Purview.Containers.Docker >= 1.0.0-prerelease.11 + net10.0: Purview.Containers.Wsl >= 1.0.0-prerelease.11 + net10.0: Microsoft.Extensions.Telemetry.Abstractions >= 10.11.0 + net10.0: Testcontainers >= 4.16.0 + Purview.Containers.Core 1.0.0-prerelease.11 + net10.0: Microsoft.Extensions.Telemetry.Abstractions >= 10.11.0 +``` + +A bare version is a minimum; brackets would mean a range. The numbers above are what the registry said +when this page was written — the shape is the part that lasts, which is why the command is here and the +versions are not presented as targets. + +## Related components that are not Purview packages + +`AspireC4.Hosting` is published under its own name and versions on its own schedule, so it is not part of +a Purview family floor. Read it the same way, with the same script. diff --git a/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs b/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs new file mode 100644 index 0000000..3069ef6 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node +/** + * Prints a package's dependency groups exactly as NuGet declares them, so the floor a component + * inherits can be read rather than guessed. + * + * The golden rule of package development is to declare the LOWEST compatible version, and that decision + * needs the real graph: which dependency, at what minimum, on which target framework. This script reads + * it from the registry - the `.nuspec` the published package carries - instead of from a table that + * would be stale by the next release. + * + * Zero dependencies: it uses the platform `fetch`, so it runs anywhere Node 18 or later does. + * + * node dependencies.mjs Purview.Containers + * node dependencies.mjs Purview.Architecture.Testing --tfm net11.0 + * node dependencies.mjs Purview.Containers --walk # recurse into Purview.* dependencies + * node dependencies.mjs Purview.Results --json + * + * An npm package is not a NuGet id; read it with `npm view <name> versions --json`. + * + * Exit codes: 0 on success, 1 when the package, version or network is not available. + */ + +/** The NuGet flat-container root: the same host that serves the package's own `.nuspec`. */ +const REGISTRY = "https://api.nuget.org/v3-flatcontainer"; + +/** How deep `--walk` follows dependencies before it stops. */ +const MAX_DEPTH = 3; + +/** True when a version string carries a prerelease label. */ +function isPrerelease(version) { + return version.includes("-"); +} + +/** Every published version of a package, oldest first. */ +async function versionsOf(id) { + const response = await fetch(`${REGISTRY}/${id.toLowerCase()}/index.json`); + if (!response.ok) { + throw new Error(`${id} is not on nuget.org (HTTP ${response.status}).`); + } + + const body = await response.json(); + return body.versions; +} + +/** The version to describe: the one asked for, else the newest stable, else the newest anything. */ +function chooseVersion(available, requested) { + if (requested) { + const match = available.find((version) => version.toLowerCase() === requested.toLowerCase()); + if (!match) { + throw new Error(`'${requested}' is not a published version. Newest is ${available.at(-1)}.`); + } + + return match; + } + + const stable = available.filter((version) => !isPrerelease(version)); + return stable.at(-1) ?? available.at(-1); +} + +/** The package's `.nuspec`, which is what a consumer's restore actually reads. */ +async function readNuspec(id, version) { + const name = id.toLowerCase(); + const response = await fetch(`${REGISTRY}/${name}/${version.toLowerCase()}/${name}.nuspec`); + if (!response.ok) { + throw new Error(`No .nuspec for ${id} ${version} (HTTP ${response.status}).`); + } + + return response.text(); +} + +/** The value of one XML attribute on a tag, or null. */ +function attribute(tag, name) { + const match = new RegExp(`${name}\\s*=\\s*"([^"]*)"`).exec(tag); + return match ? match[1] : null; +} + +/** + * How a nuspec version string constrains a consumer: a bare version is a MINIMUM, and a bracketed one is + * a range. This is the distinction the whole rule turns on, because a minimum you raise is a minimum + * every consumer inherits. + */ +function classify(range) { + if (!range) { + return "any"; + } + + return range.startsWith("[") || range.startsWith("(") ? "range" : "minimum"; +} + +/** The dependency elements inside a fragment, normalized. */ +function dependenciesIn(fragment) { + const dependencies = []; + const pattern = /<dependency\b([^>]*)\/?>/g; + let match; + + while ((match = pattern.exec(fragment)) !== null) { + const tag = match[1]; + const version = attribute(tag, "version"); + dependencies.push({ id: attribute(tag, "id"), version, constraint: classify(version) }); + } + + return dependencies; +} + +/** The dependency groups a nuspec declares, one per target framework. */ +function dependencyGroups(nuspec) { + const groups = []; + const pattern = /<group\b([^>]*)>([\s\S]*?)<\/group>/g; + let match; + + while ((match = pattern.exec(nuspec)) !== null) { + groups.push({ + framework: attribute(match[1], "targetFramework") ?? "(any)", + dependencies: dependenciesIn(match[2]), + }); + } + + if (groups.length === 0) { + const flat = dependenciesIn(nuspec); + if (flat.length > 0) { + groups.push({ framework: "(any)", dependencies: flat }); + } + } + + return groups; +} + +/** A printable name for a target framework, spelled the way the ecosystem usually writes it. */ +function shortFramework(framework) { + return framework + .replace(/^\.NETStandard/i, "netstandard") + .replace(/^\.NETCoreApp/i, "net") + .replace(/^\.NETFramework/i, "netframework") + .toLowerCase(); +} + +/** Prints one package's groups, recursing into Purview components when asked. */ +async function describe(id, requestedVersion, options, depth, seen) { + const version = chooseVersion(await versionsOf(id), requestedVersion); + const groups = dependencyGroups(await readNuspec(id, version)); + const indent = " ".repeat(depth); + + console.log(`${indent}${id} ${version}`); + + for (const group of groups) { + const framework = shortFramework(group.framework); + if (options.tfm && framework !== options.tfm.toLowerCase()) { + continue; + } + + if (group.dependencies.length === 0) { + console.log(`${indent} ${framework}: no dependencies`); + continue; + } + + for (const dependency of group.dependencies) { + const relation = dependency.constraint === "minimum" ? ">=" : dependency.constraint; + console.log( + `${indent} ${framework}: ${dependency.id} ${relation} ${dependency.version ?? "any"}`, + ); + } + + if (options.walk && depth < MAX_DEPTH) { + for (const dependency of group.dependencies) { + const isPurview = /^purview\./i.test(dependency.id ?? ""); + if (!(options.all || isPurview) || seen.has(dependency.id.toLowerCase())) { + continue; + } + + seen.add(dependency.id.toLowerCase()); + await describe(dependency.id, null, options, depth + 1, seen); + } + } + } + + return { id, version, groups }; +} + +/** Parses the command line. An unknown argument is a mistake worth failing on. */ +function parseArguments(argv) { + const options = { packages: [], tfm: null, walk: false, all: false, json: false }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + + if (argument === "--walk" || argument === "-w") { + options.walk = true; + } else if (argument === "--all") { + options.all = true; + } else if (argument === "--json") { + options.json = true; + } else if (argument === "--tfm") { + options.tfm = argv[(index += 1)]; + if (!options.tfm) { + throw new Error("--tfm needs a value, e.g. --tfm net10.0."); + } + } else if (argument.startsWith("-")) { + throw new Error(`Unknown argument '${argument}'.`); + } else { + options.packages.push(argument); + } + } + + if (options.packages.length === 0) { + throw new Error( + "Name at least one package, e.g. `node dependencies.mjs Purview.Containers`.\n" + + "A package may carry a version: `Purview.Containers@1.0.0-prerelease.11`.", + ); + } + + return options; +} + +/** The command. */ +async function main() { + const options = parseArguments(process.argv.slice(2)); + const results = []; + + for (const entry of options.packages) { + const separator = entry.indexOf("@", 1); + const id = separator === -1 ? entry : entry.slice(0, separator); + const version = separator === -1 ? null : entry.slice(separator + 1); + + results.push(await describe(id, version, options, 0, new Set([id.toLowerCase()]))); + + if (!options.walk && !options.json) { + console.log(""); + } + } + + if (options.json) { + console.log(JSON.stringify(results, null, "\t")); + } +} + +main().catch((error) => { + console.error(`error: ${error.message}`); + process.exitCode = 1; +}); diff --git a/.agents/skills/purview-engineering-principles/SKILL.md b/.agents/skills/purview-engineering-principles/SKILL.md index ba4dd9b..24fe707 100644 --- a/.agents/skills/purview-engineering-principles/SKILL.md +++ b/.agents/skills/purview-engineering-principles/SKILL.md @@ -4,8 +4,9 @@ description: >- Use when setting up, reviewing or rationalising a .NET repository against the Purview engineering conventions - convention over configuration, derived identity, architecture as tests, warnings as errors, proving consumer-facing claims, documentation ownership, test data builders, agent - legibility, and tiering verification by risk. Each rule names the failure it prevents and whether a - build or a reviewer enforces it. + legibility, tiering verification by risk, the version policy for applications and for published + components, and the conditional persistence rules. Each rule names the failure it prevents and + whether a build or a reviewer enforces it. metadata: product: Purview engineering playbook repository: purview-dev/engineering-playbook @@ -13,7 +14,7 @@ metadata: # Purview engineering principles -Ten rules, each with the failure it prevents and the mechanism that makes it true. The distinction +Eleven rules, each with the failure it prevents and the mechanism that makes it true. The distinction that matters throughout: **a convention a build can check is checked by the build; a convention only a reviewer can check is labelled a review obligation and never described as enforced.** A rule that claims enforcement it does not have is worse than no rule, because people stop looking. @@ -100,8 +101,9 @@ repository's own tests already are the consumer. ## 7. Documentation is a build artefact of the repository -The wiki lives in the repository it describes, in `docs/wiki`, and the repository owns its -`mkdocs.yml`, its navigation and its `edit_uri`. +The wiki lives in the repository it describes, in `docs/wiki`, as plain Markdown. The repository owns no +site configuration at all: `_Sidebar.md` is the navigation, `Home.md` is the hub, and the site that +publishes them lives in one place outside the repository. Documentation that lives elsewhere is updated elsewhere. A page with no owner and no review path is wrong within one release. @@ -154,6 +156,23 @@ hand-written attribute. *At your scale:* one tier is fine until the slow tests exist. Then split by project name, not by attribute. +## 11. A version is a range, not a number + +An application takes the latest version its runtime supports. A published component declares the +**lowest** version that compiles, passes its tests and satisfies the contract it documents, on each +target framework it claims — because dependency resolution is a set of lower bounds, so the floor a +component declares becomes the floor every consumer inherits. + +A page states no release number. It shows a badge generated from the registry, or links to the +registry, so a version cannot be wrong within a release. + +*Enforced by:* a lowest-compatible CI leg that restores against the declared floor, and an +API-compatibility check per target framework. Nothing enforces the floor of a package that has no +consumer yet — that tier arrives with the first release. + +*At your scale:* with one application there is nothing to earn yet; take the latest and let +`dotnet list package --outdated` report the rest. The golden rule starts at the first published package. + ## Applying these in review Three questions catch most of it: @@ -164,8 +183,16 @@ Three questions catch most of it: 3. **Could an agent — or a new joiner — find this without being told?** If not, it goes in `AGENTS.md` or the wiki. +## The conditional persistence rules + +Three further rules apply only when a repository actually stores data: a value known before storage is +derived with UUIDv5, every other GUID key uses one strategy per store, and EF Core is the persistence +boundary. They are not part of the eleven because a stateless CLI or a pure library must not inherit them. +`purview-persistence` owns them and stays silent when persistence is not real. + ## Related skills `purview-architecture` for project-shape decisions, `purview-architecture-testing` for turning a boundary into a failing build, `purview-telemetry` and `purview-persistence` for the per-concern -guidance. +guidance, and `purview-component-compatibility` for deciding and verifying a published component's +lowest compatible dependency versions. diff --git a/.agents/skills/purview-org-harvest/SKILL.md b/.agents/skills/purview-org-harvest/SKILL.md new file mode 100644 index 0000000..2c05ea1 --- /dev/null +++ b/.agents/skills/purview-org-harvest/SKILL.md @@ -0,0 +1,197 @@ +--- +name: purview-org-harvest +description: >- + Use when a Purview repository's conventions may have fallen behind the organisation - + inventorying every public purview-dev repository at main, deciding which repository is + best-in-class for each concern before adopting anything, and recording the result in a dated + snapshot. Always reads the remote: a local clone is a cache, and it may be stale, renamed or + missing entirely. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" + repository: purview-dev/engineering-playbook +--- + +# Purview organisation harvest + +Seventeen public repositories, each with its own wiki, its own `skills/` folder and its own `AGENTS.md`. +They are not equally up to date, and the one you have cloned is not necessarily the one that has the +answer. Two failures follow, and this skill exists to prevent both. + +**Copying from a stale clone.** A clone is a cache with no expiry. The convention you read there may +be two months behind the one the owner now documents, and nothing about the local file says so. + +**Copying from the wrong owner.** "How do we ship?" is answered by `build`. "How do we keep a boundary +honest?" is answered by `architests`. Asking a library that does neither produces a confident answer +with no evidence, which is worse than no answer. + +So: establish membership, read at `main`, name one owner per concern, write down what you found. + +## 1. Scope + +Name two things before reading anything: + +| Input | Example | +| -- | -- | +| The repository being changed | `containers` | +| The concern in question | "how a test reaches a real database" | + +A harvest without a named concern becomes a tour, and a tour produces a list of differences nobody +acted on. The concern is what makes "best-in-class" a decision rather than a preference. + +## 2. Discover membership + +`gh repo list` is the membership authority. A catalogue, a README table, an `llms.txt` and a local +folder listing are all convenience indexes: they are maintained by hand, so they under-count, and the +omission is silent. + +```bash +gh auth status +gh repo list purview-dev --limit 200 \ + --json name,visibility,isArchived,pushedAt,defaultBranchRef +``` + +Drop `isArchived` and anything that is not `PUBLIC`. **If `gh auth status` fails, stop.** Degrading to +the local clone is the exact failure this skill prevents, and it degrades silently. + +The filter denies by default: a repository must be exactly `PUBLIC` and exactly not archived to be +recorded, so a visibility you have never seen fails closed. `gh repo list` also only lists what your +token can see, which is why the token's visibility and the file's contents are two separate questions: +the section below is about the second one. + +A local clone is admissible only when its `HEAD` equals the SHA the snapshot recorded: + +```bash +git -C ../containers rev-parse HEAD +``` + +That is a speed optimisation, never a substitute. + +## 3. Read it at `main` + +Every repository is read at `main`, and the commit is recorded. The snapshot is what makes the claim +checkable a month later, when nobody remembers which commit the analysis saw. + +```bash +node scripts/inventory.mjs # drift between the organisation and the snapshot +node scripts/inventory.mjs --write # regenerate .config/org-inventory.json +node scripts/inventory.mjs --repo containers +``` + +The script is deterministic and dependency-free: it shells out to `gh`, so it reuses the CLI's own +authentication, and it runs the same on Windows and on a Linux agent — it is Node, not a shell script, +so there is no PowerShell edition and no Bash edition to keep in step. It writes +`.config/org-inventory.json` in the repository you run it from — generated, never hand-edited. + +To inspect a decision without a network call, replay a recorded harvest with `--from` and `--details`. +That is the same script, not a variant, so what you review is what runs. + +## 4. Harvest in two tiers + +Seventeen repositories is not a reading list; it is a budget. Two tiers keep it affordable. + +| Tier | Call | Answers | +| -- | -- | -- | +| 1 — shape | `gh api repos/purview-dev/<repo>/git/trees/main?recursive=1`, for all seventeen | Which folders exist, how many pages the wiki has, whether `skills/` is authored, whether `AGENTS.md` exists. Cheap enough for everything. | +| 2 — content | `gh api -H 'Accept: application/vnd.github.raw' repos/purview-dev/<repo>/contents/<path>`, **only for the owner of the concern** | What the convention actually says. | + +Tier 1 tells you where the evidence is. Tier 2 reads it, in the one repository that wrote it down. + +```bash +gh api repos/purview-dev/architests/git/trees/main?recursive=1 --jq '.tree[].path' +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev/architests/contents/docs/wiki/Architecture-As-Tests.md +``` + +More invocations, and what each one is for: `references/harvest-commands.md`. + +## 5. Classify: one owner per concern + +Pick exactly one owner per concern, from `references/best-in-class.md`. Two signs the classification +is wrong: + +- **Two owners for one concern.** `build-sdk` and this playbook's `purview-engineering-principles` + both answer "review this repository". That overlap is a finding to resolve, not a tie to break by + preferring whichever you read first. +- **An owner that has never run the thing it documents.** Check the tree before trusting the page. + +## 6. Divergence register + +Write the differences down before changing anything, with an adopt-or-reject for each. A divergence +with no decision recorded is the same divergence next quarter. + +| Column | Answers | +| -- | -- | +| Concern | What is being compared | +| Owner | The repository the convention comes from, and the path it is stated at | +| Here | What this repository does now | +| Decision | Adopt, or reject with the reason | +| Mechanism | The build rule, test or review obligation that keeps it true | + +Rejecting is a legitimate outcome and is usually the right one at a smaller scale. What is not +legitimate is adopting a convention whose failure the repository cannot have. + +## 7. Adopt + +A convention arrives as a set of changes that land together, or it is a claim with no mechanism: + +```text +the page states the rule, why, how it is enforced, and what a smaller repository does instead +src/ demonstrates it, or says why it does not +the snapshot records the commit it was read at +the skill points at the owner, and never paraphrases it +``` + +**Link to the owner; do not restate it.** A paraphrase is a second source of truth, and the copy with +no owner is the one that loses. + +## 8. Verify + +```bash +just build # zero warnings +just test # architecture, skill and documentation rules +just lint-check +node scripts/inventory.mjs --print +``` + +If the harvest changed what the snapshot should record, regenerate it and commit the diff. The +snapshot being current is a review obligation rather than a build failure — a build that goes red on +a Tuesday because a clock passed is a build people disable. + +## Private repositories + +A private repository may inform a maintainer's judgement. It is never evidence, and it is never +written down. This is not a preference, and it is not a heuristic an agent gets to weigh against a +good argument. Five statements, in order of how often they are broken: + +1. A committed snapshot contains public, non-archived repositories only. The writer denies by default + and refuses to persist anything else, and a test asserts it. +2. A private repository may be read when `gh` is authenticated for it, and what it teaches may shape + a rule. +3. Nothing published names it: not the snapshot, not the page, not the skill, not a workflow, not a + config, not a commit message. Attribute the rule to "a Purview application repository", or to + nothing. +4. **If a rule cannot be justified without the private source, it is not published.** +5. **If you cannot say where a name came from, do not write the name.** A repository the snapshot + does not list is either private or gone, and both fail the same test. + +The fourth is the one an agent rationalises around, because the evidence is often excellent. A rule +whose only justification is out of reach of the people it is published to cannot be reviewed by them, +which makes it folklore with a citation they cannot follow. + +## Checks before you answer + +- Did you read membership from the organisation rather than from a catalogue or a folder? +- Is every claim about a repository true at the SHA the snapshot recorded, not at your clone? +- Did you name exactly one owner per concern, and resolve any overlap rather than duplicating it? +- Did you reject anything? A harvest with no rejections usually means nothing was understood. +- Does every change you propose have a mechanism, or is it labelled a review obligation? +- Is every repository you named public? + +## See also + +- `references/best-in-class.md` — which repository owns which concern, and where to read it +- `references/harvest-commands.md` — the `gh` invocations, and the trap in each one +- `purview-engineering-principles` — the conventions themselves, once you know which owner to read +- `purview-architecture` — the project-shape decisions a harvest most often touches diff --git a/.agents/skills/purview-org-harvest/references/best-in-class.md b/.agents/skills/purview-org-harvest/references/best-in-class.md new file mode 100644 index 0000000..9ab69cc --- /dev/null +++ b/.agents/skills/purview-org-harvest/references/best-in-class.md @@ -0,0 +1,77 @@ +# Best-in-class, by concern + +One owner per concern. Read the owner, not the nearest repository that happens to have a wiki. + +Every row was read from the organisation with `gh repo list purview-dev` and +`gh api repos/<owner>/git/trees/main?recursive=1`. The page counts and skill counts are the shape at +the time of writing, not a promise: they are what the snapshot records, and the snapshot is +regenerated rather than trusted. + +## The matrix + +| Concern | Owner | Read it at | +| -- | -- | -- | +| Repository shape, MSBuild defaults, analyzers, skill authoring | `build-sdk` | `docs/wiki`, `templates/`, `skills/purview-buildsdk-*` | +| Pipeline, release, pack validation | `build` | `docs/wiki`, `skills/purview-build-*` | +| Architecture policy: dependency direction, naming rules | `architests` | `docs` (nine pages; `Policy-Reference.md` is the reference, `Extending.md` the how-to), `skills/purview-architests-*` | +| Local dependencies in tests | `containers` | `docs/wiki`, `skills/purview-containers-*` | +| TypeScript contracts generated from C# | `zodsharp` | `docs/wiki`, `skills/purview-zodsharp-*` | +| Logging, tracing, metrics | `telemetry-sourcegenerator` | `docs/wiki`, `skills/purview-telemetry-*` | +| Event streams and event storage | `event-sourcing` | `docs/wiki` — **no skills folder** | +| Operation outcomes without exceptions | `results` | `docs/wiki`, `skills/purview-results-*` | +| Validated identity types | `value-objects` | `docs` — **no `docs/wiki`** | +| Aspire hosting resources | `aspire-resourcekit` | `docs/wiki`, `skills/purview-resourcekit-*` | +| Architecture diagrams generated from source | `aspirec4` | `docs/wiki`, `skills/purview-aspirec4-*` | +| LikeC4 diagrams inside an Astro or Starlight site | `astroc4` | `docs/wiki`, `skills/purview-astroc4-*` — an npm package, not .NET | +| Source generator authoring | `sourcegenerator-framework` | `docs/wiki`, `skills/purview-sourcegenerator-*` | +| Skill-set design, and the dated-snapshot pattern | `pm-skills` | `skills/pm-*` — **no documentation folder at all** | +| OpenAPI generated from XML comments | `openapi-xmlcomments` | `docs/wiki`, `skills/purview-openapi-xmlcomments-*` | +| The reusable conventions themselves | `engineering-playbook` | `docs/wiki`, `skills/purview-*` | +| The public project catalogue | `purview-dev.github.io` | `src/src/data/projects/*.yml` | + +`purview-dev.github.io` is the catalogue, and a catalogue is an index rather than a membership list: +an entry is added by hand, so a repository missing from it is silent. `gh repo list` is the authority. + +## The overlaps worth knowing about + +**`build-sdk` and `engineering-playbook` both answer "review this repository".** They are not +duplicates, and the split is worth stating because a harvest will hit it immediately: + +- `build-sdk` owns *what the tooling does* — project detection, the props and targets, the analyzer + set, the packaged test-support rule sets. +- `engineering-playbook` owns *why a repository is shaped that way, and what it costs* — the rule, + the failure it prevents, and what a smaller repository does instead. + +A question about a property name goes to `build-sdk`. A question about whether a project should exist +goes here. + +**`containers` and `aspire-resourcekit` both look like "local dependencies".** `containers` owns the +test-time resource — a real SQL Server, PostgreSQL or queue — and `aspire-resourcekit` owns the +development-time orchestration. A test that needs a database is `containers`; an AppHost that needs +one is `aspire-resourcekit`. + +**`aspirec4` and `astroc4` both draw LikeC4 diagrams.** `aspirec4` owns the diagram generated from a +running Aspire resource graph; `astroc4` owns putting a `.c4` model inside an Astro or Starlight site — +the fence transform and the LikeC4 syntax highlighting. A question about the .NET host is `aspirec4`; a +question about the documentation site is `astroc4`, which is the one owner here that is not a .NET +package, so it is read for its TypeScript conventions rather than for its MSBuild ones. + +## Reading a repository you have not cloned + +Tier 1 first — the shape, for everything: + +```bash +gh api repos/purview-dev/<repo>/git/trees/main?recursive=1 --jq '.tree[].path' +``` + +Then Tier 2 — the content, for the one owner: + +```bash +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev/<owner>/contents/docs/wiki/_Sidebar.md +``` + +`_Sidebar.md` is the fastest index of a wiki, because the repository's own documentation rule says a +page that is not in it does not exist. Where a repository publishes no wiki, its `AGENTS.md` is the +next best entry point, and where it has neither, read `skills/*/SKILL.md` — the trigger in each +`description` says what the repository thinks it is for. diff --git a/.agents/skills/purview-org-harvest/references/harvest-commands.md b/.agents/skills/purview-org-harvest/references/harvest-commands.md new file mode 100644 index 0000000..f4b4653 --- /dev/null +++ b/.agents/skills/purview-org-harvest/references/harvest-commands.md @@ -0,0 +1,121 @@ +# Harvest commands + +Every invocation here is safe to run read-only, and each one is followed by the trap that cost +somebody time. Both shells are shown because the harvest runs on a developer's Windows machine and on +a Linux agent, and the quoting rules are not the same. + +## Membership + +```bash +gh auth status +gh repo list purview-dev --limit 200 --json name,visibility,isArchived,pushedAt,defaultBranchRef +``` + +```powershell +gh auth status +gh repo list purview-dev --limit 200 --json name,visibility,isArchived,pushedAt,defaultBranchRef | + ConvertFrom-Json | Sort-Object name | + Format-Table name, visibility, isArchived, pushedAt +``` + +**Trap: the default limit is 30.** Without `--limit` the list is silently short, and a short list is +worse than a failed one because the omission never announces itself. + +**Trap: `gh repo list` shows what your token can see.** An organisation member sees private +repositories too; filter to `visibility -eq 'PUBLIC'` and `-not isArchived` before recording anything. + +## The SHA a convention was read at + +```bash +gh api repos/purview-dev/<repo>/git/ref/heads/main --jq .object.sha +``` + +**Trap: a renamed repository resolves by redirect.** Asking for a repository under its former name +answers `purview-dev/purview-dev.github.io` and exits 0, so a stale name never fails. Compare the +returned `full_name` with the name you asked for. + +## Tier 1: the shape + +```bash +gh api repos/purview-dev/<repo>/git/trees/main?recursive=1 --jq '.tree[].path' +``` + +**Trap: `truncated`.** GitHub caps a recursive tree, and the response says so with `"truncated": true` +rather than erroring. A truncated tree under-counts skills and pages, so check the flag before +believing a zero. + +**Trap: the tree lists blobs and trees.** Filter to `type == "blob"` when counting files, or a folder +whose name matches your pattern is counted as its contents. + +## Tier 2: the content + +```bash +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev/<owner>/contents/docs/wiki/_Sidebar.md +``` + +**Trap: `contents` returns JSON unless you ask for raw.** Without the `Accept` header the response is +a base64 `content` field, and the analysis quietly proceeds on a JSON document. + +**Trap: `contents` caps at 1000 files for a directory listing.** Use the tree API for enumerating and +`contents` for reading one file. + +## Windows PowerShell, specifically + +**Embedded double quotes are stripped from a native command's arguments.** This looks correct and is +not: + +```powershell +gh repo list purview-dev --jq '.[] | [.name, (.pushedAt // "-")] | @tsv' +``` + +`gh` receives `// -` and answers `failed to parse jq expression ... unexpected token`. Either keep the +jq program free of string literals, or do the shaping in PowerShell: + +```powershell +$repos = gh repo list purview-dev --limit 200 --json name,visibility | ConvertFrom-Json +$repos | Sort-Object name | ForEach-Object { '{0}|{1}' -f $_.name, $_.visibility } +``` + +**A filtered result is a scalar when it is one item.** `(... | Where-Object { ... }).Count` returns +nothing rather than `1`, so `.Count` in a summary line reads as empty. Wrap it: `@(...).Count`. + +## The snapshot + +```bash +node skills/purview-org-harvest/scripts/inventory.mjs +node skills/purview-org-harvest/scripts/inventory.mjs --write +node skills/purview-org-harvest/scripts/inventory.mjs --repo containers --write +``` + +It resolves the snapshot path from `git rev-parse --show-toplevel`, so it writes into the repository +you are standing in rather than into the one the script was installed from. That is what lets another +repository adopt it unchanged. + +`--repo` merges on write, so reading one repository does not erase the other sixteen. + +## Replaying a harvest, with no network + +Two flags replace the two `gh` calls. This is how the repository's own tests exercise the script on +every platform without touching GitHub, and how a reviewer reproduces what a harvest decided: + +```bash +node skills/purview-org-harvest/scripts/inventory.mjs \ + --from recorded-membership.json \ + --details recorded-details.json \ + --print +``` + +`--from` takes exactly what `gh repo list --json name,visibility,isArchived,pushedAt,defaultBranchRef` +printed. `--details` takes an object keyed by repository name, each with the `commitSha` a tier-1 +harvest found and the `paths` the tree contained. A repository that is in the membership and missing +from the details is an error, not a silent omission — a replay that quietly skipped a repository would +look like a clean run. + +Two behaviours worth knowing before you trust a run: + +- **A snapshot carrying a byte-order mark is accepted.** It is the one file in this workflow a person + is likely to open and re-save, and a BOM is not a corruption. +- **A snapshot containing anything that is not public and unarchived is refused**, and nothing is + written. This includes an entry a hand edit left behind, which the merge would otherwise carry + forward: delete it and re-run `--write`. diff --git a/.agents/skills/purview-org-harvest/scripts/inventory.mjs b/.agents/skills/purview-org-harvest/scripts/inventory.mjs new file mode 100644 index 0000000..affc46f --- /dev/null +++ b/.agents/skills/purview-org-harvest/scripts/inventory.mjs @@ -0,0 +1,466 @@ +#!/usr/bin/env node +/** + * Snapshots every public, non-archived `purview-dev` repository at `main` into + * `.config/org-inventory.json`, and reports the drift between that snapshot and the organisation. + * + * The snapshot is what makes the harvest rule checkable offline: `OrgInventoryTests` reads the file + * the pipeline has, never the network. This script is the only thing that writes it, so a repository + * cannot appear in the snapshot by being remembered. + * + * Zero dependencies: it shells out to `gh` and so reuses the CLI's own authentication rather than + * asking for a token. + * + * node inventory.mjs # print the drift against the snapshot (default) + * node inventory.mjs --write # rewrite the snapshot from the organisation + * node inventory.mjs --repo containers # narrow to one repository; merges on --write + * node inventory.mjs --out <path> # write somewhere else, e.g. while adopting the skill + * node inventory.mjs --help + * + * Exit codes: 0 on success, including when drift is found - a stale snapshot is a review obligation, + * not a red pull request for whoever happened to push next. 1 when `gh` is missing, unauthenticated, + * or the organisation cannot be read. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** The organisation this playbook describes. */ +const ORG = "purview-dev"; + +/** The branch a convention is read at. A local clone is a cache; this is the authority. */ +const BRANCH = "main"; + +/** Bumped when a field is added or its meaning changes. */ +const SCHEMA_VERSION = 1; + +/** Past this age the snapshot is reported as stale. Advisory: nothing fails on a clock. */ +const STALE_AFTER_DAYS = 30; + +/** Files a repository may put at its root to guide an agent. Recorded in this order. */ +const AGENT_GUIDANCE_FILES = ["AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md", "llms.txt"]; + +/** Directories a published documentation site is read from, most specific first. */ +const DOCS_DIRECTORIES = ["docs/wiki", "docs"]; + +/** + * The concern each repository is best-in-class for, so a harvest reads one owner instead of sixteen + * repositories. `references/best-in-class.md` is the long form of this table and is the file to edit + * first; this map only records the answer in the snapshot. + */ +const CONCERNS = new Map([ + ["architests", ["architecture policy", "dependency and naming rules"]], + ["aspire-resourcekit", ["Aspire hosting resources"]], + ["aspirec4", ["architecture diagrams from source"]], + ["astroc4", ["architecture diagrams in an Astro site"]], + ["build", ["pipeline", "release"]], + ["build-sdk", ["repository shape", "analyzers and style", "MSBuild defaults", "skill authoring"]], + ["containers", ["local dependencies in tests"]], + ["engineering-playbook", ["the conventions themselves"]], + ["event-sourcing", ["event streams", "event storage"]], + ["openapi-xmlcomments", ["OpenAPI from XML comments"]], + ["pm-skills", ["skill-set design"]], + ["purview-dev.github.io", ["the public project catalogue"]], + ["results", ["operation outcomes"]], + ["sourcegenerator-framework", ["source generator authoring"]], + ["telemetry-sourcegenerator", ["logging", "tracing", "metrics"]], + ["value-objects", ["validated identity types"]], + ["zodsharp", ["TypeScript contracts from C#"]], +]); + +/** Runs `gh` and returns stdout, turning a CLI failure into an actionable message. */ +function gh(args) { + try { + return execFileSync("gh", args, { + encoding: "utf8", + maxBuffer: 256 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + const detail = (error.stderr ?? "").toString().trim() || error.message; + throw new Error( + `gh ${args.join(" ")} failed.\n${detail}\n\n` + + "This script reads the organisation, not a local clone. Run `gh auth status`, then retry.", + ); + } +} + +/** Calls one GitHub REST endpoint and parses the JSON response. */ +function api(endpoint) { + return JSON.parse(gh(["api", endpoint])); +} + +/** The root of the repository the command was run in, so the skill works from any repository. */ +function repositoryRoot() { + try { + return execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch { + throw new Error("Not inside a git repository. Run this from a clone, or pass --out <path>."); + } +} + +/** Membership, as the organisation reports it. The catalogue is a convenience index, not the list. */ +function readMembership(source) { + const listed = source + ? JSON.parse(readFileSync(source, "utf8")) + : JSON.parse( + gh([ + "repo", + "list", + ORG, + "--limit", + "200", + "--json", + "name,visibility,isArchived,pushedAt,defaultBranchRef", + ]), + ); + + return ( + listed + // Deny by default. The filter is "exactly public and not archived" rather than "not + // private", so a visibility value this script has never seen fails closed instead of + // falling through into a file that is committed to a public repository. + .filter((repository) => repository.visibility === "PUBLIC" && repository.isArchived === false) + .sort((left, right) => left.name.localeCompare(right.name, "en")) + ); +} + +/** The recorded commit and file list for each repository, when a run replays a previous harvest. */ +function readDetails(source) { + return new Map(Object.entries(JSON.parse(readFileSync(source, "utf8")))); +} + +/** The Markdown pages directly inside a docs directory. */ +function countPages(paths, directory) { + const prefix = `${directory}/`; + return paths.filter((path) => { + if (!path.startsWith(prefix)) { + return false; + } + + const remainder = path.slice(prefix.length); + return remainder.endsWith(".md") && !remainder.includes("/"); + }).length; +} + +/** A skill is a folder under `skills/` that carries a `SKILL.md`. */ +function authoredSkills(paths) { + const names = paths + .filter((path) => /^skills\/[^/]+\/SKILL\.md$/.test(path)) + .map((path) => path.split("/")[1]); + + return [...new Set(names)].sort((left, right) => left.localeCompare(right, "en")); +} + +/** Everything the snapshot records about one repository, read at `BRANCH`. */ +function describeRepository(repository, details) { + // The filter above already excluded these, and this is the second lock on the same door: a + // private repository must never reach the snapshot, so the guard is here as well as there. + if (repository.visibility !== "PUBLIC" || repository.isArchived !== false) { + throw new Error( + `${ORG}/${repository.name} is ${repository.visibility} and archived ${repository.isArchived}. ` + + "The snapshot records public, unarchived repositories only, so nothing was written.", + ); + } + + const defaultBranch = repository.defaultBranchRef?.name ?? BRANCH; + if (defaultBranch !== BRANCH) { + throw new Error( + `${ORG}/${repository.name} has default branch '${defaultBranch}', not '${BRANCH}'. ` + + "The snapshot pins one branch, so either the rule or the repository has to change.", + ); + } + + const recorded = details?.get(repository.name); + if (details && !recorded) { + throw new Error( + `${repository.name} has no recorded detail, so --details cannot describe it. ` + + "Replay a membership the details file was captured with, or run without --details.", + ); + } + + const commitSha = recorded?.commitSha ?? api(`repos/${ORG}/${repository.name}/git/ref/heads/${BRANCH}`).object.sha; + const paths = recorded?.paths ?? readTree(repository.name); + const docsDir = DOCS_DIRECTORIES.find((candidate) => + paths.some((path) => path.startsWith(`${candidate}/`)), + ); + + return { + name: repository.name, + visibility: repository.visibility.toLowerCase(), + archived: repository.isArchived, + commitSha, + pushedAt: repository.pushedAt, + docsDir: docsDir ?? null, + docsPages: docsDir ? countPages(paths, docsDir) : 0, + skills: authoredSkills(paths), + agentGuidance: AGENT_GUIDANCE_FILES.filter((file) => paths.includes(file)), + concerns: CONCERNS.get(repository.name) ?? [], + }; +} + +/** The blob paths of one repository at `BRANCH`. */ +function readTree(name) { + const tree = api(`repos/${ORG}/${name}/git/trees/${BRANCH}?recursive=1`); + + if (tree.truncated) { + process.stderr.write( + `warning: ${name}'s tree is truncated, so its skills and docs are undercounted\n`, + ); + } + + return tree.tree.filter((entry) => entry.type === "blob").map((entry) => entry.path); +} + +/** The snapshot document. */ +function snapshotOf(repositories) { + return { + schemaVersion: SCHEMA_VERSION, + org: ORG, + branch: BRANCH, + fetchedAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + repositories, + }; +} + +/** The snapshot on disk, or null when the repository has not committed one yet. */ +function readSnapshot(path) { + if (!existsSync(path)) { + return null; + } + + // The snapshot is the one file in this workflow a person is likely to open and save by hand, and + // an editor that writes a byte-order mark produces a file JSON.parse rejects with a message that + // names neither the file nor the cause. + return JSON.parse(readFileSync(path, "utf8").replace(/^\uFEFF/, "")); +} + +/** A field's value in the form the drift report prints. */ +function render(value) { + if (value === null || value === undefined) { + return "none"; + } + + if (Array.isArray(value)) { + return value.length === 0 ? "none" : value.join(", "); + } + + return String(value); +} + +/** A field's value in the form the drift report compares: structured values compare as one string. */ +function fingerprint(value) { + return JSON.stringify(value ?? null); +} + +/** The fields whose change is worth reporting, in the order a reader cares about them. */ +const COMPARED_FIELDS = [ + "commitSha", + "docsDir", + "docsPages", + "skills", + "agentGuidance", + "concerns", + "visibility", + "archived", +]; + +/** The differences between the organisation and the snapshot. */ +function drift(existing, observed) { + const findings = []; + const before = new Map((existing?.repositories ?? []).map((entry) => [entry.name, entry])); + const after = new Map(observed.map((entry) => [entry.name, entry])); + + for (const [name, entry] of after) { + if (!before.has(name)) { + findings.push(`${name}: not in the snapshot`); + continue; + } + + const previous = before.get(name); + for (const field of COMPARED_FIELDS) { + if (fingerprint(previous[field]) === fingerprint(entry[field])) { + continue; + } + + findings.push(`${name}: ${field} ${render(previous[field])} -> ${render(entry[field])}`); + } + } + + for (const name of before.keys()) { + if (!after.has(name)) { + findings.push(`${name}: in the snapshot but no longer a public repository`); + } + } + + return findings; +} + +/** How long ago the snapshot was taken, in whole days, or null when it carries no usable date. */ +function ageInDays(fetchedAt) { + const fetched = Date.parse(fetchedAt ?? ""); + return Number.isNaN(fetched) ? null : Math.floor((Date.now() - fetched) / 86_400_000); +} + +/** Parses the command line. Unknown arguments are a mistake worth failing on. */ +function parseArguments(argv) { + const options = { write: false, repos: [], out: null, from: null, details: null, help: false }; + + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]; + if (argument === "--write") { + options.write = true; + } else if (argument === "--print") { + options.write = false; + } else if (argument === "--help" || argument === "-h") { + options.help = true; + } else if (argument === "--repo") { + const value = argv[++index]; + if (!value) { + throw new Error("--repo needs a repository name."); + } + + options.repos.push(value); + } else if (argument === "--out") { + const value = argv[++index]; + if (!value) { + throw new Error("--out needs a path."); + } + + options.out = value; + } else if (argument === "--from") { + const value = argv[++index]; + if (!value) { + throw new Error("--from needs a path to a recorded `gh repo list` response."); + } + + options.from = value; + } else if (argument === "--details") { + const value = argv[++index]; + if (!value) { + throw new Error("--details needs a path to a recorded detail file."); + } + + options.details = value; + } else { + throw new Error(`Unknown argument '${argument}'. Run with --help.`); + } + } + + return options; +} + +/** The documented usage. */ +const HELP = `Snapshot every public, non-archived ${ORG} repository at ${BRANCH}. + + node inventory.mjs print the drift between the organisation and the snapshot + node inventory.mjs --write rewrite the snapshot from the organisation + node inventory.mjs --repo <name> narrow to one repository; merges when writing + node inventory.mjs --out <path> write somewhere other than .config/org-inventory.json + node inventory.mjs --from <path> replay a recorded "gh repo list --json" response instead of + asking the organisation + node inventory.mjs --details <path> replay recorded commits and file lists, so a run touches no + network at all; pair it with --from + node inventory.mjs --help this text + +The snapshot is generated, never hand-edited. A stale snapshot is a review obligation rather than a +build failure, so drift prints and the exit code stays 0.`; + +/** Runs the command. */ +function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.help) { + console.log(HELP); + return; + } + + const snapshotPath = options.out ?? join(repositoryRoot(), ".config", "org-inventory.json"); + const existing = readSnapshot(snapshotPath); + + let observed = readMembership(options.from); + if (options.repos.length > 0) { + const known = new Set(observed.map((repository) => repository.name)); + for (const name of options.repos) { + if (!known.has(name)) { + throw new Error( + `'${name}' is not a public, non-archived ${ORG} repository. ` + + `Run without --repo to include all ${observed.length}.`, + ); + } + } + + observed = observed.filter((repository) => options.repos.includes(repository.name)); + } + + const details = options.details ? readDetails(options.details) : null; + const described = observed.map((repository) => describeRepository(repository, details)); + + if (options.write) { + const merged = new Map((existing?.repositories ?? []).map((entry) => [entry.name, entry])); + for (const entry of described) { + merged.set(entry.name, entry); + } + + const written = snapshotOf( + [...merged.values()].sort((left, right) => left.name.localeCompare(right.name, "en")), + ); + + // The merge carries forward whatever the file already held, which is how a hand-edited entry + // would survive a regeneration. Refuse to write a snapshot that records anything else. + const notPublic = written.repositories.filter( + (entry) => entry.visibility !== "public" || entry.archived !== false, + ); + if (notPublic.length > 0) { + throw new Error( + "Refusing to write a snapshot that records a repository which is not public and " + + `unarchived: ${notPublic.map((entry) => entry.name).join(", ")}. ` + + "Delete the entries and re-run --write.", + ); + } + + mkdirSync(dirname(snapshotPath), { recursive: true }); + writeFileSync(snapshotPath, `${JSON.stringify(written, null, "\t")}\n`, "utf8"); + console.log( + `wrote ${written.repositories.length} repositories to ${snapshotPath} at ${written.fetchedAt}`, + ); + return; + } + + const findings = drift(existing, described); + const age = ageInDays(existing?.fetchedAt); + + console.log(`${ORG}: ${observed.length} public, non-archived repositories at ${BRANCH}`); + if (existing) { + console.log( + `snapshot: ${existing.repositories?.length ?? 0} repositories, fetched ${existing.fetchedAt}` + + (age === null ? "" : ` (${age} day${age === 1 ? "" : "s"} old)`), + ); + } else { + console.log(`no snapshot at ${snapshotPath} - run with --write to create one`); + } + + if (findings.length === 0) { + console.log("no drift"); + } else { + console.log(`${findings.length} difference${findings.length === 1 ? "" : "s"}:`); + for (const finding of findings) { + console.log(` ${finding}`); + } + } + + if (age !== null && age > STALE_AFTER_DAYS) { + console.log( + `warning: the snapshot is older than ${STALE_AFTER_DAYS} days. ` + + "Re-run with --write and review the diff.", + ); + } +} + +try { + main(); +} catch (error) { + console.error(`error: ${error.message}`); + process.exitCode = 1; +} diff --git a/.agents/skills/purview-persistence/SKILL.md b/.agents/skills/purview-persistence/SKILL.md index 4667fa2..7d6ad91 100644 --- a/.agents/skills/purview-persistence/SKILL.md +++ b/.agents/skills/purview-persistence/SKILL.md @@ -1,14 +1,15 @@ --- name: purview-persistence description: >- - Use when a .NET project actually persists data and you are choosing a storage approach, deciding - whether to abstract EF Core, or selecting a GUID primary-key strategy. Gives provider-specific - guidance — SQL Server and PostgreSQL differ and must not be treated alike — and stays silent for - projects that do not persist anything. + Use when a .NET project actually persists data and you are choosing a storage approach, integrating + or abstracting EF Core, deriving a well-known identifier, or selecting a GUID primary-key strategy. + Gives provider-specific guidance — SQL Server and PostgreSQL differ and must not be treated alike — + and stays silent for projects that do not persist anything. license: MIT metadata: author: Purview-Dev - version: "1.0" + version: "1.2" + repository: purview-dev/engineering-playbook --- # Purview persistence @@ -40,7 +41,39 @@ development and integration tests — including `MsSql`, `PostgreSql`, `MySql`, `RabbitMq`, `Azurite` and an Azure Key Vault emulator. Prefer a real container over an in-memory provider: the in-memory provider does not enforce the constraints that break you in production. -## 3. GUID keys — only if GUIDs are keys +## 3. EF Core integration + +When EF Core is the store, keep the integration in four places: + +- **`DbContext` is the boundary.** Use it directly; do not add `IRepository<T>` or a unit-of-work wrapper + for testability. `Purview.Containers` gives you a real database in tests. +- **Choose the provider where the context is registered.** Carry the choice as context-options data, not + a `Database.ProviderName` branch at model-build time. +- **Map value objects once.** Configure converters and comparers in the model, not per property. A + value-object key needs a converter-aware value generator. +- **Keep design time and runtime in step.** Both paths make the same provider, strategy and + `ReplaceService` choices, or the migration snapshot describes a different model. + +Migrations are code, reviewed and applied by a deliberate step; the API does not migrate at startup in +production. The full rule, composition sample and checks are in `references/ef-core-integration.md`. + +## 4. Identifiers — derived or generated + +Two decisions live here, and they are independent. Ask them in this order. + +### The deterministic-identifier gate + +Derive an identifier only when its **value must be known before storage** — a system tenant, a seeded +reference row, an installation's service principal, or an ingested object whose provider key is stable +and never recycled. Then derive it with UUIDv5 over an installation-scoped namespace and configure +`ValueGeneratedNever()`, so the sequential convention below leaves it alone. A project with random keys +and no seeding has no use for UUIDv5. An outbox row that merely needs an id before `SaveChanges` takes +the generated strategy below, not a derived one. + +The full rule, catalogue, naming rules, migration caveat and tests are in +`references/well-known-identifiers.md`. + +### The generated-key decision Resolve these before recommending anything; most are answered by the repository: @@ -72,11 +105,14 @@ Use the framework or database capability when it is sufficient — `Guid.CreateV generator is a maintenance obligation and a correctness risk; introduce one only against a measured requirement. -**There is no Purview GUID generation package.** Do not reference one, and do not create one without -first establishing a concrete, measured need that the built-in options cannot meet. If such a -capability is added later, this skill is where the guidance for recommending it belongs. +**No NuGet package provides this pattern.** A reviewed generator such as `FastGuid` supplies a +store-ordered implementation, but the strategy witness, options extension, value generator with +value-converter support, and model-finalising convention are repository-local: copy them, do not depend +on a package that claims to own the wiring. Introduce it only for a measured requirement: a second +provider, or an identifier needed before `SaveChanges`. Until the second provider exists, a built-in +capability is the answer. -## 4. Value objects as keys +## 5. Value objects as keys `Purview.ValueObjects` gives strongly-typed identifiers that stop an `OrderId` being passed where a `CustomerId` belongs. They need an EF Core value converter; configure it once in the model rather @@ -85,7 +121,7 @@ than per property. This is about type safety at the boundary, not about the generation strategy — the two decisions are independent. -## 5. Migrations and schema +## 6. Migrations and schema - Migrations are code: reviewed, tested against a real container, and applied by a deliberate step — not by the application at startup in production. @@ -96,7 +132,14 @@ independent. ## Checks before you answer - Have you confirmed persistence is actually required? +- If the project uses EF Core, are you recommending the `DbContext` directly rather than a repository + wrapper added for testability? +- Does the design-time path make the same provider and strategy choices as the runtime path? +- Does every generated value have one owner — application, store or derivation — and no second writer? +- If you are deriving an identifier, is its value genuinely known before storage, and is it keyed + `ValueGeneratedNever()`? - If you are recommending a GUID strategy, do you know the provider *and* its version? +- If you are proposing the strategy/convention, is there a second provider or a pre-save identifier? - Are you proposing an abstraction you can justify by something other than testability? - Have you preferred a built-in capability over a custom generator? diff --git a/.agents/skills/purview-persistence/references/ef-core-integration.md b/.agents/skills/purview-persistence/references/ef-core-integration.md new file mode 100644 index 0000000..77c7573 --- /dev/null +++ b/.agents/skills/purview-persistence/references/ef-core-integration.md @@ -0,0 +1,83 @@ +# EF Core integration + +Read this when the project actually uses EF Core. The identifier decisions are in `guid-generation.md` +and `well-known-identifiers.md`; this page is the rest of the integration. + +## The rule + +1. **`DbContext` is the boundary.** Read and write through it directly, or through a narrow query type + when a query deserves a name. Do not add `IRepository<T>` or a unit-of-work wrapper for testability. + `DbContext` already is both, and a real container is a more faithful test than a substitute. +2. **The provider is chosen where the context is registered.** Carry the choice as context-options data; + never branch on `Database.ProviderName` while the model is being built. The model is built once and + cached, so that branch serves one provider's model to the other. +3. **Map a value object once.** Configure the converter and comparer in the model, not on each property. + The `Purview.ValueObjects` generator emits the converters when EF Core is referenced. A value-object + key also needs a converter-aware value generator, or the property receives a raw `Guid`. +4. **One owner per generated value.** The application supplies it, the store generates it, or it is + derived — never two. A derived identifier is `ValueGeneratedNever()`; a store default or computed + column is left to the store. +5. **Design time and runtime agree.** The design-time factory or host registration makes the same + provider, strategy and `ReplaceService` choices as the application. Otherwise the migration snapshot + describes a model the application does not run. +6. **The model cache key covers every input that varies the model.** Provider, strategy, tenant and + design time. EF's service-provider cache and model cache are separate; both need a key that matches + the decision. +7. **Migrations are code.** Persistence owns the definitions; a deliberate step applies them. The API + does not migrate at startup in production. +8. **Integration tests use a real store.** The in-memory provider does not enforce foreign keys, unique + indexes, computed columns or SQL semantics. Use `Purview.Containers` and a `<Target>.IntegrationTests` + project. +9. **The connection string is configuration.** Keep it out of source, out of `appsettings.json` in the + repository, and out of telemetry. A redaction test is worth more than a convention. + +## The composition + +The provider, the strategy and the service replacements belong together, where the context is +registered: + +```csharp +services.AddDbContext<AcmeDbContext<TProvider>>((serviceProvider, options) => +{ + ConfigureStore<TProvider>(options, connectionString); + + options + .UseSequentialGuids<TProvider>() + .ReplaceService<IModelCacheKeyFactory, AcmeModelCacheKeyFactory>() + .ReplaceService<IModelCustomizer, AcmeModelCustomizer<TProvider>>(); +}); + +services.AddSingleton<ISequentialGuidFactory, SequentialGuidFactory<TProvider>>(); +``` + +- `ConfigureStore` is the only place that names `UseSqlServer` or `UseNpgsql`. +- `UseSequentialGuids` adds an options extension; the model reads it instead of asking about the provider. +- `IModelCacheKeyFactory` is replaced because the service-provider hash alone does not key the model + cache. +- The application-facing factory is registered separately, because EF's internal registration is not + reachable from an outbox writer or a correlation-id generator. + +## Migrations + +- Generation and application are separate: `dotnet ef migrations add` in development, a controlled + `Migrator` step in a deployment. +- A migration and `HasData` are compiled and replayed, so they need a literal GUID. A derived identifier + cannot be computed there; print the constant from the derivation and guard it with a test. +- Expect the schema to be ahead of or behind the code during a rollout, and design the change to be + compatible in both directions. +- Never let the API apply migrations at startup in production: a deploy then mutates the schema without + review and without a clean rollback. + +## Checks to write + +| Check | Shape | +| -- | -- | +| Model covers every GUID key | Each non-foreign-key GUID key is generated on add with the expected factory | +| Foreign keys are not generated | A foreign-key GUID keeps the value copied from its principal | +| Explicit configuration wins | A `ValueGeneratedNever()` key keeps its supplied value through `SaveChanges` | +| Two strategies, one context type | Different strategies produce different service providers and models | +| Design time matches runtime | Both models produce the same migration snapshot | +| Real store semantics | A unique index, a foreign key and a computed column behave as the model says in a container | + +The Engineering Playbook's *EF Core Integration* page states the same rules with the sample as evidence; +the runnable implementation is `samples/Acme.Persistence` in that repository, run with `just samples`. diff --git a/.agents/skills/purview-persistence/references/guid-generation.md b/.agents/skills/purview-persistence/references/guid-generation.md index 432053a..fb644bb 100644 --- a/.agents/skills/purview-persistence/references/guid-generation.md +++ b/.agents/skills/purview-persistence/references/guid-generation.md @@ -62,14 +62,22 @@ Never use `NEWSEQUENTIALID()` output as a secret. | Provider | Needs value before insert | Locality matters | Recommendation | | -- | -- | -- | -- | -| SQL Server | No | Yes | `NEWSEQUENTIALID()` (EF Core's default for `Guid` keys) | -| SQL Server | Yes | Yes | EF Core `SequentialGuidValueGenerator` | +| SQL Server | No | Yes | `NEWSEQUENTIALID()` as a store default, or EF Core's client-side `SequentialGuidValueGenerator` | +| SQL Server | Yes | Yes | EF Core `SequentialGuidValueGenerator`, or a store-ordered `FastGuid` strategy when both providers share a model | | SQL Server | Either | No | `Guid.NewGuid()` | | PostgreSQL | Yes | Yes | `Guid.CreateVersion7()` with `ValueGeneratedNever()` | | PostgreSQL | No | Yes | `uuidv7()` on 18+, otherwise generate v7 in the application | | PostgreSQL | Either | No | `Guid.NewGuid()` or `gen_random_uuid()` | +| SQL Server **and** PostgreSQL, one context type | Either | Yes | A store-specific strategy carried as context-options data. One strategy per provider; see below. | | Any | Either | Either, but must be unpredictable | Random v4 | +The two-provider row is the one that needs code: an `ISequentialGuidProvider` witness, an options +extension and a model-finalising convention. Copy them from the Engineering Playbook's *GUID Key +Strategy* page. Never expect one byte-ordered generator to order for both stores. + +A value whose identity is known before storage does not belong in this table. Derive it with UUIDv5 and +key it `ValueGeneratedNever()`; see `well-known-identifiers.md`. + ## Prefer built-in capability In order of preference: @@ -77,17 +85,19 @@ In order of preference: 1. A framework capability — `Guid.CreateVersion7()`, EF Core's value generators. 2. A database capability — `NEWSEQUENTIALID()`, `uuidv7()`, `gen_random_uuid()`. 3. A reviewed, tested implementation of a published standard, when neither of the above is available - on your target versions. + on your target versions — `FastGuid` is the one the sample uses for store-ordered values. 4. A bespoke generator — only against a measured requirement the first three cannot meet. A hand-written sequential GUID generator is easy to get subtly wrong — endianness, clock regression, counter rollover within a millisecond, and thread safety are all live hazards, and the failure mode is duplicate or badly-ordered keys found in production. -## There is no Purview GUID package +## No NuGet package provides this -No Purview library provides GUID generation today. Do not reference one and do not invent one. +No NuGet package provides the strategy/convention pattern. It is repository-local: copy the strategy +witness, options extension, value generator and model-finalising convention from the Engineering +Playbook's *GUID Key Strategy* page. Do not depend on a package that claims to own it, and do not +create one without a measured requirement the built-in options cannot meet. -If a concrete need emerges that the built-in options cannot meet — established by measurement, not by -anticipation — that is the point at which creating one is worth discussing. Until then this guidance -is the deliverable, and this page is where a recommendation for such a package would be documented. +The abstraction is earned by a second provider, or by an identifier needed before `SaveChanges` on +both — not by anticipation. Until then a built-in capability is the answer. diff --git a/.agents/skills/purview-persistence/references/postgresql.md b/.agents/skills/purview-persistence/references/postgresql.md index cd557ec..603987d 100644 --- a/.agents/skills/purview-persistence/references/postgresql.md +++ b/.agents/skills/purview-persistence/references/postgresql.md @@ -43,6 +43,49 @@ builder.Property(o => o.Id).ValueGeneratedNever(); Without that, EF Core may treat the key as store-generated and skip your value. +### Application-generated, store-ordered strategy + +When one context type serves both PostgreSQL and SQL Server, or the identifier must exist before +`SaveChanges` on both, use a store-specific strategy carried as context-options data. The PostgreSQL +implementation returns `FastGuid.NewPostgreSqlGuid()`; a model-finalising convention applies it to +every GUID key and leaves explicitly keyed derived identifiers alone. + +```csharp +public sealed class PostgreSqlSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "PostgreSql"; + + public static Guid NewGuid() => FastGuid.NewPostgreSqlGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewPostgreSqlGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.PostgreSql.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} +``` + +- PostgreSQL compares `uuid` in plain big-endian byte order, so both `FastGuid.NewPostgreSqlGuid()` + and `Guid.CreateVersion7()` are genuinely sequential here. `FastGuid` is also correct for SQL Server, + which is the point of the strategy abstraction. +- Set `ValueGeneratedNever()` through the convention; never let EF fetch a store-generated value that + does not exist. + +#### RFC caveat + +`FastGuid.NewPostgreSqlGuid()` values are **not** RFC 9562 UUIDs. They are ordinary GUIDs with a +PostgreSQL-ordered layout, so PostgreSQL's `uuid_extract_timestamp()` will not read them even though +they are valid `uuid` values. Use the provider's `GetTimestamp` (above) instead. If another system +needs a standard timestamp, generate `Guid.CreateVersion7()` there and accept that the layout differs. + +The full sample is the Engineering Playbook's *GUID Key Strategy* page. The abstraction is earned by +the second provider, not by anticipation. + ### Database-generated UUIDv7 — PostgreSQL 18+ PostgreSQL 18 added a native `uuidv7()` function. Earlier versions do not have it; on 13–17 you have @@ -87,6 +130,7 @@ Random and unpredictable. Fine at low to moderate write volume, which covers mos | .NET 8 or earlier, high write volume | A reviewed UUIDv7 implementation, or accept v4 after measuring | | Rows inserted outside the application, PostgreSQL 18+ | `uuidv7()` as a column default | | Rows inserted outside the application, PostgreSQL 13–17 | `gen_random_uuid()` | +| One model serving PostgreSQL and SQL Server | A store-specific `FastGuid` strategy carried as context-options data — never one byte-ordered generator for both | | Key must be unpredictable | `Guid.NewGuid()` or `gen_random_uuid()` — v7 leaks creation time | | Low or moderate volume | v4 — the locality difference will not be your bottleneck | diff --git a/.agents/skills/purview-persistence/references/sqlserver.md b/.agents/skills/purview-persistence/references/sqlserver.md index c728295..2262972 100644 --- a/.agents/skills/purview-persistence/references/sqlserver.md +++ b/.agents/skills/purview-persistence/references/sqlserver.md @@ -27,8 +27,13 @@ ALTER TABLE [Orders] ADD CONSTRAINT [DF_Orders_Id] DEFAULT NEWSEQUENTIALID() FOR [Id]; ``` -EF Core, for a `Guid` key on SQL Server, configures this by default — `ValueGeneratedOnAdd` maps to -`NEWSEQUENTIALID()` through the default value-generation strategy. +EF Core does **not** map `ValueGeneratedOnAdd` to `NEWSEQUENTIALID()` by default. For a `Guid` key on +SQL Server the provider generates the value client-side with `SequentialGuidValueGenerator` (below). +Configure a store default explicitly when the database must own the value: + +```csharp +builder.Property(o => o.Id).HasDefaultValueSql("NEWSEQUENTIALID()"); +``` Trade-offs: @@ -53,6 +58,44 @@ builder.Property(o => o.Id) Use this when the identifier must exist before `SaveChanges`. It keeps most of the index locality while letting the application own the value. +### Application-generated, store-ordered strategy + +When one context type serves both SQL Server and PostgreSQL, or the identifier must exist before +`SaveChanges` on both, a single built-in generator is not enough: the two stores order GUIDs +differently. The answer is a store-specific strategy carried as context-options data — a +`static abstract` witness whose SQL Server implementation returns `FastGuid.NewSqlServerGuid()`, plus a +value generator and a model-finalising convention that applies it to every GUID key. + +```csharp +public sealed class SqlServerSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "SqlServer"; + + public static Guid NewGuid() => FastGuid.NewSqlServerGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewSqlServerGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.SqlServer.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} +``` + +- The increasing part must sit where SQL Server sorts it. The trailing six bytes dominate + `uniqueidentifier`; a byte-ordered UUIDv7 does not. +- The generator must hand the value through the property's value converter, or a `readonly record + struct OrderId` key receives a raw `Guid`. +- Apply it at convention precedence, so explicit `ValueGeneratedNever()`, a store default and a + computed column all win. + +The full sample is the Engineering Playbook's *GUID Key Strategy* page. The abstraction is earned by +the second provider; on one SQL Server provider `NEWSEQUENTIALID()` or EF Core's generator is enough. + ### `Guid.NewGuid()` — random Correct, portable and unpredictable; poor index locality. @@ -88,7 +131,7 @@ reaching for a sequential generator. | Situation | Use | | -- | -- | | High insert volume, key may be database-generated | `NEWSEQUENTIALID()` | -| High insert volume, identifier needed before persisting | EF Core `SequentialGuidValueGenerator` | +| High insert volume, identifier needed before persisting | EF Core `SequentialGuidValueGenerator`, or a store-ordered `FastGuid` strategy when both providers share a model | | Key must be unpredictable (public surface, security boundary) | `Guid.NewGuid()`, with a non-clustered PK | | Low or moderate volume | `Guid.NewGuid()` — do not optimise what does not hurt | | You want a sortable timestamp in the key and accept the locality cost | `Guid.CreateVersion7()` | diff --git a/.agents/skills/purview-persistence/references/well-known-identifiers.md b/.agents/skills/purview-persistence/references/well-known-identifiers.md new file mode 100644 index 0000000..372d585 --- /dev/null +++ b/.agents/skills/purview-persistence/references/well-known-identifiers.md @@ -0,0 +1,93 @@ +# Deriving well-known identifiers with UUIDv5 + +Read this only when an identifier's **value must be known before storage**: a system tenant, a seeded +reference row, an installation's service principal, or an ingested object whose provider key is stable +and never recycled. A project with random keys and no seeding has no use for UUIDv5 — say nothing and +move on. An outbox row that merely needs an id before `SaveChanges` takes a generated strategy instead. + +## The rule + +1. Derive the value: `Uuid5.Create(namespace, name)` is RFC 4122 version 5 (SHA-1), byte-identical for + the same namespace and name in every process, language and restart. +2. Scope the namespace to the isolation boundary — one installation or one deployment. Never a global + constant shared by every deployment. +3. Fix the name as a canonical constant of the identity kind. Not a display name, not user data, not a + secret. +4. Once persisted, the value is authoritative. Never recompute it and update the row. +5. Key it explicitly with `ValueGeneratedNever()`, so a sequential-GUID convention never generates over + a value the application already knows. + +## The catalogue + +Write the known identities down in one place. + +| Identity | Namespace | Name | Derived when | Immutable | +| -- | -- | -- | -- | -- | +| System tenant of an installation | installation id | `system-tenant` | first bootstrap | yes | +| System principal of an installation | installation id | `system-service-principal` | first bootstrap | yes | +| Seeded reference row | installation id | `<entity>:<code>` | seed step | yes | +| Ingested external object (optional) | installation id | `<provider>:<external-id>` | first ingest | yes | + +Derive the ingested row only when the provider guarantees the external key is stable and never +recycled. If it can be recycled, keep the local key sequential and make the external key a unique +column. + +## Naming rules + +- Lowercase ASCII, hyphen-separated, no whitespace, no locale-dependent casing. +- Never from a display name, email, URL, title, or anything a user can edit. +- Never from a secret, and never as a capability token. A name is guessable. +- Renaming a name creates a new identity. Renaming a persisted one is a data migration. + +## Bootstrap sequence + +Read the persisted identity; if present it wins, and a configured value must match or the operation +fails. If absent, insert it. Derive dependants from the persisted id. Save once. A second run changes +nothing. + +The guard matters as much as the insert: + +```csharp +if (persisted is not null && persisted.Id != configured) +{ + throw new InvalidOperationException( + $"Configured installation id '{configured.Value}' does not match the persisted id '{persisted.Id.Value}'."); +} +``` + +## Migrations cannot call the helper + +`HasData` and migration SQL need a literal GUID. A small script prints ready-to-paste constants from +the same derivation, and a test asserts every printed constant equals `Uuid5.Create(...)`. Changing the +namespace or a name after a migration has shipped is a data migration, not an edit. + +## Checks to write + +| Check | Shape | +| -- | -- | +| RFC 4122 conformance | Fixed vectors for DNS/URL/OID/X500 and the empty name; exact GUIDs | +| Determinism | Same pair twice equal; different names differ | +| Version and variant bits | Byte 6 high nibble `0x5`; byte 8 high bits `10` | +| Cross-language equality | Python `uuid.uuid5` or Node `crypto` reproduces the vectors | +| Catalogue uniqueness | The catalogue's constants are distinct | +| Bootstrap idempotency | Seeding twice changes zero rows; a mismatched configured id throws | +| Explicit keying | The key is `ValueGenerated.Never`, and the sequential convention leaves it alone | + +```text +uuid5(6ba7b810-9dad-11d1-80b4-00c04fd430c8, "example.com") = cfbff0d1-9375-5685-968c-48ce8b15ae17 +uuid5(6ba7b811-9dad-11d1-80b4-00c04fd430c8, "www.example.com") = b63cdfa4-3df9-568e-97ae-006c5b8fd652 +uuid5(6ba7b812-9dad-11d1-80b4-00c04fd430c8, "1.2.3.4") = 67448b45-6d15-536b-bef7-8a78c3d10ac6 +uuid5(6ba7b814-9dad-11d1-80b4-00c04fd430c8, "CN=Bob") = 0840e90d-9ca0-5f14-970e-26294d44cb1d +``` + +The hash path is big-endian. `Guid.ToByteArray()` and `new Guid(byte[])` use a mixed-endian layout and +produce a different hash; that is the most common way a "UUIDv5" implementation is wrong. + +**UUIDv5 is not sequential.** Never use it for a high-write table's primary key. It is for a small, +known set of identities; everything else takes `guid-generation.md`. + +RFC 4122 is the version-5 specification; RFC 9562 is the current UUID specification and retains the +algorithm. The .NET BCL has no UUIDv5 API, so the helper is a small hand-written function. + +The Engineering Playbook's *Well-Known Identifiers* page carries the full helper, catalogue and +bootstrap samples, and their test shapes. diff --git a/.agents/skills/purview-site-assessment/SKILL.md b/.agents/skills/purview-site-assessment/SKILL.md new file mode 100644 index 0000000..64afe45 --- /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/<id>.yml -o .purview-entry.yml +``` + +`<id>` 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 <this-skill>/scripts/assess.mjs . --catalogue .purview-entry.yml +``` + +`<this-skill>` is where the skill was installed (for example `.agents/skills/purview-site-assessment`). +Options: `--docs <path>` 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/<repo> --jq '{description, topics, archived, default_branch, has_discussions}' +gh api "repos/purview-dev/<repo>/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: <repo> + +<verdict: ready / ready after the blockers / not ready, and why, in one paragraph> + +## 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 <folder>`. +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 0000000..245e585 --- /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="<id>"` | +| 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/<name>/` 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 0000000..644c8b5 --- /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 <path>] [--catalogue <entry.yml>] [--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/<id>.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*purview:likec4\b(.*?)-->\s*$/; +const DIAGRAM_CLOSE = /^ {0,3}<!--\s*\/purview:likec4\s*-->\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 `<!-- /purview:likec4 -->` 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 `<!-- /purview:likec4 -->` 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/<name>.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="<id>" 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}: <current-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 0000000..9e916d3 --- /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/<project-id>/`. 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/<id>.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/<id>/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/<id>/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 +<!-- purview:likec4 file="./diagrams/request-flow.c4" view="requestFlow" title="Request flow" --> +```mermaid +flowchart LR + client[Client] --> api[API] --> store[(Store)] +``` + +[LikeC4 model](./diagrams/request-flow.c4) +<!-- /purview:likec4 --> +```` + +- **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 0000000..3710364 --- /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 +<!-- purview:likec4 file="./diagrams/the-loop.c4" view="theLoop" title="The loop" --> +```mermaid +flowchart LR + resume[pm-resume] --> next[pm-next] +``` + +[LikeC4 model](./diagrams/the-loop.c4) +<!-- /purview:likec4 --> +```` + +| Part | Rule | +| -- | -- | +| Opening marker | `<!-- purview:likec4 … -->` 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 | `<!-- /purview:likec4 -->` 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/<name>.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 + +<!-- purview:likec4 file="./docs/diagrams/the-loop.c4" view="theLoop" title="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) +<!-- /purview:likec4 --> + +| 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 0000000..ba8a033 --- /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 `<docs.path>/**/*.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 (`<repo>.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/<id>/` | +| `docs/wiki/Getting-Started.md` | `/docs/<id>/getting-started/` | +| `docs/wiki/guide/README.md` | `/docs/<id>/guide/` | +| `docs/wiki/guide/First-Project.md` | `/docs/<id>/guide/first-project/` | +| `docs/wiki/Q&A/Why Not.md` | `/docs/<id>/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/<id>/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/<repo>/blob/<ref>/src/Thing.cs` | +| `![Flow](./images/flow.png)` | `https://github.com/<repo>/raw/<ref>/<docs.path>/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/<id>/`) and `prerelease` (the default branch, at +`/docs/<id>/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 0000000..7658587 --- /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/<id>/getting-started/` to `/docs/<id>/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 + +`<docs root>/_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/<id>/ +``` + +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 0000000..24cd40a --- /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/<id>.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/<id>/` | `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/<id>/<name>/` | every `skills/<name>/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/<repo>`, 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/<repo> --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/<id>.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/<name>/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 0000000..d945fa4 --- /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/<id>.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/<id>/`, `/docs/<id>/` | +| `name` | display name; must slugify to `id` when the project has docs (`Build SDK` → `build-sdk`). Not prefixed with "Purview" | +| `repository` | `purview-dev/<repo>` | +| `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/<name>.json` | + +Redirects for moved pages belong in this repository's `_redirects.yml`, not in the entry. + +## A template to fill in + +```yaml +id: <id> +name: <Name> +shortDescription: <one sentence> +description: >- + <2–4 sentences> +origin: >- + <why it became a project> +useWhen: >- + <when it fits> +avoidWhen: >- + <when it does not> +repository: purview-dev/<repo> +category: <category> +status: <stable|preview> +docs: + source: github-path + path: docs/wiki + rootPage: Home.md + exclude: + - _Sidebar.md +packages: + - id: <Package.Id> + description: <one line> + primary: true +useCases: + - audience: developer + title: <outcome-first title> + scenario: >- + <the situation> + code: | + <minimal real code> + language: csharp + outcome: >- + <what the reader gets> + evidence: >- + <what proves it> + docsPage: <root-page-slug> +``` diff --git a/.agents/skills/purview-telemetry/SKILL.md b/.agents/skills/purview-telemetry/SKILL.md index 6d83173..d2f42d7 100644 --- a/.agents/skills/purview-telemetry/SKILL.md +++ b/.agents/skills/purview-telemetry/SKILL.md @@ -9,6 +9,7 @@ license: MIT metadata: author: Purview-Dev version: "1.0" + repository: purview-dev/engineering-playbook --- # Purview telemetry @@ -120,4 +121,4 @@ Instrumentation is identical across all three rows. Only the right-hand column g ## See also - `purview-architecture` — establishing project context and scale before deciding -- [Purview.Telemetry.SourceGenerator](https://github.com/purview-dev/purview-telemetry-sourcegenerator) +- [Purview.Telemetry.SourceGenerator](https://github.com/purview-dev/telemetry-sourcegenerator) diff --git a/.agents/skills/site-validation-loop/SKILL.md b/.agents/skills/site-validation-loop/SKILL.md new file mode 100644 index 0000000..56f6e26 --- /dev/null +++ b/.agents/skills/site-validation-loop/SKILL.md @@ -0,0 +1,120 @@ +--- +name: site-validation-loop +description: Use when validating a change to the Purview-Dev website — what each just recipe and script checks, the data-sync modes, how to reproduce failures offline, and the rules for generated artefacts. +category: purview-dev-website +roles: + - validation + - tooling +tags: + - just + - bun + - ci + - astro +--- + +# site-validation-loop Skill + +`just validate` is the authoritative chain and the same pipeline runs in CI +(`purview-build.json` → `bun run ci:build`; deploy.yml runs `just validate` first). Treat a green +`just validate` as the definition of done. + +## The chain, in order + +| Step | Command | Catches | +| --- | --- | --- | +| Format check | `just format-check` (`oxfmt --check`) | Formatting drift in `src/**` (not `.astro`) | +| Lint | `just lint` (`oxlint`, `maxWarnings: 0`) | Type-aware + a11y issues in `src/**` | +| Typecheck | `just typecheck` (`astro check` + strict TS) | Component/frontmatter/content-schema type errors | +| Unit tests | `just test` (`bun test tests/unit`) | Manifest/schema rules, catalogue guard, docs aggregation rules, release transform, urls, staleness, branding | +| Assets | `just check-assets` | Branding sources vs generated public assets | +| Catalogue | `just check-projects` | Catalogue invariants (see below) | +| Build | `just build` (runs live data sync first) | Astro/Starlight build, sidebar/llms plugin wiring | +| Links | `just check-links` | Broken internal links **and missing anchors** across `dist/**/*.html` | +| Generated output | `just check-generated` | Docs cache/mirror agreement, release cache shape, mirrored project assets exist and are valid JSON, `llms*.txt`, sitemap, robots, no secrets/local paths | +| Dist tests | `just test:dist` | Per-project LLM bundles, rendered project/use-case surfaces, footer version, SEO outputs | + +## The catalogue guard (`just check-projects`) + +`src/scripts/check-projects.ts` validates the catalogue records (`src/src/data/`) against the generated data (offline, +deterministic). It fails the build on: + +- ids/orders duplicated, `order` not a unique integer, `name` not slugifying to `id` for documented + projects; +- a documented project with no aggregated pages, or a `docsPage` that does not resolve; +- a non-archived project with no use case, or a use case with `code` but no `language`; +- a package declared more than once, more than one `primary`, or a package with no release data; +- `status: archived` on a non-archived repository, or `stable` on a project whose packages have no + stable version; +- missing repository topics (tags), a non-`main` default branch for a documented project, or + `discussions: true` without repository discussions; +- `externalProjects` pointing at a `purview-dev` repository. + +Beyond those blockers, the guard prints **advisory observations** (they never fail the build) when a +project disagrees with the versions and frameworks its packages have published: + +- a `preview` project whose packages already publish a stable version on their registry (unless `experimental`); +- a NuGet package that is deprecated or unlisted; +- packages that trail the project's headline version; +- `targetFrameworks` that do not match the packages' NuGet nuspec metadata. Project-level frameworks + are compared with the union of the **runtime** packages' frameworks; package-level frameworks are + compared with that package's own. `netstandard*` is treated as an analyzer/generator build target + (never a consumer framework), and `install: msbuild-sdk`/`dotnet-tool` projects and the `msbuild` + sentinel are skipped. + +Run it alone while iterating: `just check-projects`. + +Apply the machine-applicable observations (missing/mismatched `targetFrameworks`, `preview` → +`stable` when a stable package exists) with `just fix-projects`; it is a dry run by default and only +edits the records with `--write`. It rewrites the affected `targetFrameworks` lists to exactly the +expected set (so it also removes frameworks a package no longer targets), but never touches blockers +or judgement calls such as replacing a deprecated package. + +## Data-sync modes + +`DATA_MODE` (`src/.env.example`) controls how docs/release data is obtained: + +- `auto` (default) — use the cache if present, else fetch live, else fall back to committed fixtures. +- `live` — always fetch from GitHub and the package registries (`just live-data-sync`). +- `cache` — only use the existing cache (fails if missing). +- `fixture` — only use `src/fixtures/**` (fully offline, deterministic). + +The same mode governs mirrored **project assets** (`assets:` in a record): `just data-sync` fetches +each declared repository file with the docs aggregator's `fetchRawFile`, writes it to `public/`, and +caches it under `.cache/assets/`. A declared asset that cannot be resolved fails the sync, so a +missing schema never silently ships a broken URL. + +`DATA_FALLBACK_TO_FIXTURES=false` makes `auto` fail loudly instead of silently using fixtures. Set +`GITHUB_TOKEN` (or `gh auth token`) to avoid unauthenticated rate limits. + +The one thing the **browser** fetches is the package **version numbers** a page already displays +(ADR 0008, `src/src/lib/releases/live.ts`): the build-time value renders first, a strictly higher +live version replaces it, and the result is stored in `localStorage` for six hours. The home-page +snapshot, the package tables on `/releases/` and project pages, and the version pinned in the +install snippets refresh this way. Nothing else is fetched client-side, and no check depends on the +live value — `just check-projects` and `tests/dist` assert the build-time output. + +## Rules + +- **Never edit generated files** (`src/src/content/docs/**`, `src/.cache/**`, `src/dist/**`). + Regenerate with `just data-sync`, `just fetch-releases`, `just build`, or `just refresh-fixtures`. +- **Never weaken a check** to make a change pass. Fix the data or the implementation; changing the + contract needs an ADR. +- Keep `src/fixtures/**` in sync when the release data shape changes (`just refresh-fixtures`, then + review the diff). +- Do not run `just clean` to "fix" a failure: a fresh checkout then needs a live data sync. + +## Reproducing failures + +```shell +# Docs aggregation / sidebar problems +just live-data-sync && just check-generated + +# Link or anchor failures: rebuild then re-crawl +just build && just check-links + +# Catalogue drift in isolation +just check-projects + +# Release-data problems without network +DATA_MODE=fixture just build && just test-dist +``` diff --git a/.agents/skills/use-case-authoring/SKILL.md b/.agents/skills/use-case-authoring/SKILL.md new file mode 100644 index 0000000..84279ef --- /dev/null +++ b/.agents/skills/use-case-authoring/SKILL.md @@ -0,0 +1,84 @@ +--- +name: use-case-authoring +description: Use when writing or reviewing useCases in the catalogue records — the ADR 0002 rules for audience-tagged, concrete, evidence-backed examples with short code snippets and resolvable documentation deep links. +category: purview-dev-website +roles: + - catalogue + - content +tags: + - use-cases + - adr-0002 + - documentation +--- + +# use-case-authoring Skill + +Use cases are the "what does it actually do for me" evidence on the home page, on each project page, +and on `/use-cases/`. They are authored in the catalogue records (`src/src/data/projects/<id>.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/<id>/ +``` + +## 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/<project-id>/<docsPage>/`). 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/<id>/`) — 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/.agents/skills/vscode-extension-screenshot-evidence/SKILL.md b/.agents/skills/vscode-extension-screenshot-evidence/SKILL.md new file mode 100644 index 0000000..5a0e0e2 --- /dev/null +++ b/.agents/skills/vscode-extension-screenshot-evidence/SKILL.md @@ -0,0 +1,182 @@ +--- +name: vscode-extension-screenshot-evidence +description: Use when a LikeC4 PR or bug investigation needs real before/after screenshots from the actual VS Code extension or VS Code webview preview. Trigger for VS Code preview rendering bugs, extension-only visual regressions, PR descriptions that require screenshots from VS Code, or requests to prove a VS Code fix visually without substituting browser-only screenshots. +--- + +# VS Code Extension Screenshot Evidence + +Capture visual evidence from a real VS Code Extension Development Host. Use browser or Playwright screenshots only when the user explicitly asks for browser evidence; VS Code extension bugs need VS Code screenshots. + +## Ground rules + +- Capture the real VS Code UI with the extension loaded through `--extensionDevelopmentPath`. +- Use isolated temp user-data and extensions directories so local VS Code state does not affect the screenshot. +- Use `origin/main` or the PR base for "before" and the PR branch for "after". +- Keep screenshots and temporary fixtures out of the fix branch unless the user explicitly wants image assets committed. +- Visually inspect the captured images before adding them to a PR. +- Do not publish screenshots until you have confirmed they contain no secrets, usernames, home-directory paths, private tabs, unrelated files, or unrelated windows. +- Mention any hosting fallback used for images. GitHub `user-attachments` uploads require a browser `user_session` cookie or `GH_SESSION_TOKEN`; the normal `gh` token is not enough. + +## Prerequisites + +Check the tools before spending time on builds: + +```bash +command -v code +command -v Xvfb +command -v xdotool +command -v import +command -v python3 +command -v file +``` + +`import` is from ImageMagick. Always run the capture command under an isolated Xvfb display, even when `DISPLAY` is already set. Use an explicit screen size so the default 1280x900 window is not cropped: + +```bash +xvfb-run -a -s "-screen 0 1280x1024x24" ... +``` + +The helper script runs without `--allow-live-display` only when it verifies that `DISPLAY` belongs to an active Xvfb process. All other display values, including `:2` and remote displays, require `--allow-live-display` for manual debugging. Do not use live-desktop captures as PR evidence unless you have cropped or redacted them. + +## Build the extension under test + +For LikeC4 preview changes, build both the preview package and the VS Code extension package in each worktree you capture: + +```bash +pnpm generate +pnpm --filter @likec4/vscode-preview build +pnpm --filter likec4-vscode build +``` + +If you create a clean before worktree from `origin/main`, install dependencies only when `node_modules` are missing or stale. + +## Create a minimal fixture + +Keep the fixture focused on the visual behavior. For an icon-color regression, a single `.c4` file is enough: + +```likec4 +specification { + element component { + style { + shape component + icon bootstrap:file-earmark-code + iconColor amber + } + } +} + +model { + component test 'Test' +} + +views { + view index { + include * + } +} +``` + +Store fixtures under `/tmp` unless they are intended to become committed tests. + +## Capture before and after + +Use the helper script from this skill: + +```bash +SKILL=.agents/skills/vscode-extension-screenshot-evidence +FIXTURE=/tmp/likec4-vscode-screenshot-fixture +OUT=/tmp/likec4-vscode-screens + +mkdir -p "$OUT" + +xvfb-run -a -s "-screen 0 1280x1024x24" "$SKILL/scripts/capture-likec4-vscode-preview.sh" \ + --label before \ + --fixture "$FIXTURE" \ + --extension-path /tmp/likec4-before/packages/vscode \ + --output "$OUT/before-vscode.png" + +xvfb-run -a -s "-screen 0 1280x1024x24" "$SKILL/scripts/capture-likec4-vscode-preview.sh" \ + --label after \ + --fixture "$FIXTURE" \ + --extension-path /path/to/pr-worktree/packages/vscode \ + --output "$OUT/after-vscode.png" +``` + +The script opens the command palette, runs `LikeC4: Open Preview`, selects the default view, waits for the webview, and captures the VS Code window. + +If the command palette flow changes, prefer adjusting the script inputs or timing over taking manual screenshots. Manual screenshots are acceptable only when automation is blocked and the PR explicitly says so. + +## Inspect evidence + +Check file metadata and visually inspect both images: + +```bash +identify "$OUT/before-vscode.png" "$OUT/after-vscode.png" +``` + +Expected evidence should show: + +- the same fixture and VS Code preview in both screenshots +- the reported broken state in the before image +- the fixed state in the after image +- enough surrounding VS Code UI to prove the capture is not a browser-only rendering +- no usernames, home-directory paths, tokens, private tabs, unrelated files, or unrelated windows + +## Add screenshots to the PR + +Prefer GitHub attachment URLs when the `gh image` extension and a session token are available: + +```bash +gh extension list | grep -F "drogers0/gh-image" || gh extension install drogers0/gh-image +gh image --repo likec4/likec4 "$OUT/before-vscode.png" "$OUT/after-vscode.png" +``` + +`gh image` is not a built-in `gh` command. It uses GitHub's web upload flow and needs a browser `user_session` cookie or `GH_SESSION_TOKEN`; treat that session token like a password. + +If `gh image` fails because browser cookies or `GH_SESSION_TOKEN` are unavailable, use a dedicated asset branch rather than committing images into the fix branch: + +```bash +ASSET_BRANCH=cgk/pr-NNNN-vscode-screenshots +ASSET_WORKTREE=$(mktemp -d /tmp/likec4-pr-NNNN-assets.XXXXXX) +ASSET_REPO=OWNER/likec4 + +cd "$ASSET_WORKTREE" +git init +git remote add origin "https://github.com/${ASSET_REPO}.git" +git checkout --orphan "$ASSET_BRANCH" +mkdir -p pr-assets/prNNNN +cp "$OUT/before-vscode.png" pr-assets/prNNNN/before-vscode.png +cp "$OUT/after-vscode.png" pr-assets/prNNNN/after-vscode.png +git add pr-assets/prNNNN/before-vscode.png pr-assets/prNNNN/after-vscode.png +if ! git config user.name >/dev/null || ! git config user.email >/dev/null; then + echo "Set Git author identity before committing: git config user.name 'Your Name'; git config user.email 'you@example.com'" >&2 + exit 1 +fi +git commit -m "docs: add prNNNN vscode screenshots" +git push origin HEAD:refs/heads/"$ASSET_BRANCH" +``` + +Use a fork for `ASSET_REPO` unless you intentionally want a maintainer-owned upstream asset branch. If you use the upstream repository, delete the asset branch after the PR no longer needs it: + +```bash +git push origin --delete "$ASSET_BRANCH" +``` + +Then embed raw URLs in the PR body: + +```markdown +## VS Code screenshots + +Captured from a real VS Code Extension Development Host under Xvfb against the issue fixture. +Screenshots were checked for sensitive or unrelated visible content before upload. + +| Before (`origin/main`) | After (this PR) | +| ------------------------------------------------ | --------------------------------------------- | +| ![Before: describe broken state](RAW_BEFORE_URL) | ![After: describe fixed state](RAW_AFTER_URL) | +``` + +After editing, verify the PR body: + +```bash +gh pr view PR --repo likec4/likec4 --json body,url +``` diff --git a/.agents/skills/vscode-extension-screenshot-evidence/scripts/capture-likec4-vscode-preview.sh b/.agents/skills/vscode-extension-screenshot-evidence/scripts/capture-likec4-vscode-preview.sh new file mode 100644 index 0000000..6535338 --- /dev/null +++ b/.agents/skills/vscode-extension-screenshot-evidence/scripts/capture-likec4-vscode-preview.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + capture-likec4-vscode-preview.sh \ + --label before|after \ + --fixture /path/to/fixture-folder \ + --extension-path /path/to/likec4/packages/vscode \ + --output /path/to/screenshot.png + +Options: + --command "LikeC4: Open Preview" VS Code command palette text to run. + --wait-seconds 18 Seconds to wait after selecting the view. + --window-size 1280x900 Screenshot window size. + --allow-live-display Allow running with an unverified DISPLAY value. + --keep-temp Keep temporary VS Code user-data dirs for debugging. +USAGE +} + +label="" +fixture="" +extension_path="" +out_png="" +command_text="LikeC4: Open Preview" +wait_seconds="18" +window_size="1280x900" +allow_live_display="false" +keep_temp="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --label) + label="${2:-}" + shift 2 + ;; + --fixture) + fixture="${2:-}" + shift 2 + ;; + --extension-path) + extension_path="${2:-}" + shift 2 + ;; + --output) + out_png="${2:-}" + shift 2 + ;; + --command) + command_text="${2:-}" + shift 2 + ;; + --wait-seconds) + wait_seconds="${2:-}" + shift 2 + ;; + --window-size) + window_size="${2:-}" + shift 2 + ;; + --allow-live-display) + allow_live_display="true" + shift + ;; + --keep-temp) + keep_temp="true" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$label" || -z "$fixture" || -z "$extension_path" || -z "$out_png" ]]; then + usage >&2 + exit 2 +fi + +for required in code xdotool import python3 file; do + if ! command -v "$required" >/dev/null 2>&1; then + echo "Missing required command: $required" >&2 + exit 1 + fi +done + +if [[ -z "${DISPLAY:-}" ]]; then + echo "DISPLAY is not set. Run this script under xvfb-run -a or start Xvfb first." >&2 + exit 1 +fi + +is_isolated_xvfb_display() { + local display_number="${DISPLAY%%.*}" + local pid + local command + local arguments + local argument + + [[ "$display_number" =~ ^:[0-9]+$ ]] || return 1 + + while read -r pid command; do + [[ "$command" == "Xvfb" ]] || continue + arguments="$(ps -p "$pid" -o args=)" + for argument in $arguments; do + [[ "$argument" == "$display_number" ]] && return 0 + done + done < <(ps -eo pid=,comm=) + + return 1 +} + +if ! is_isolated_xvfb_display && [[ "$allow_live_display" != "true" ]]; then + echo "DISPLAY=$DISPLAY is not verified as an isolated Xvfb display." >&2 + echo "Run under xvfb-run -a -s '-screen 0 1280x1024x24', or pass --allow-live-display for manual debugging." >&2 + exit 1 +fi + +if [[ ! -d "$fixture" ]]; then + echo "Fixture folder does not exist: $fixture" >&2 + exit 1 +fi + +if [[ ! -d "$extension_path" ]]; then + echo "Extension path does not exist: $extension_path" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$out_png")" + +search_code_windows() { + { + xdotool search --onlyvisible --classname code 2>/dev/null || true + xdotool search --onlyvisible --class code 2>/dev/null || true + xdotool search --onlyvisible --class Code 2>/dev/null || true + } | awk '!seen[$0]++' +} + +existing_windows="$(search_code_windows || true)" +fixture_name="$(basename "$fixture")" +user_data="$(mktemp -d "/tmp/likec4-vscode-${label}-user.XXXXXX")" +extensions_dir="$(mktemp -d "/tmp/likec4-vscode-${label}-extensions.XXXXXX")" +log_file="${out_png%.png}.log" + +mkdir -p "${user_data}/User" +USER_DATA="$user_data" python3 - <<'PY' +import json +import os +from pathlib import Path + +settings = { + "workbench.colorTheme": "Default Dark Modern", + "workbench.startupEditor": "none", + "security.workspace.trust.enabled": False, + "telemetry.telemetryLevel": "off", + "window.restoreWindows": "none", + "workbench.editor.enablePreview": False, +} + +Path(os.environ["USER_DATA"], "User", "settings.json").write_text( + json.dumps(settings, indent=2), + encoding="utf-8", +) +PY + +code \ + --new-window \ + --wait \ + --user-data-dir "$user_data" \ + --extensions-dir "$extensions_dir" \ + --extensionDevelopmentPath="$extension_path" \ + --disable-gpu \ + --disable-workspace-trust \ + --skip-welcome \ + --skip-release-notes \ + "$fixture" >"$log_file" 2>&1 & +code_pid="$!" +window_id="" + +cleanup() { + set +e + if [[ -n "${window_id:-}" ]]; then + xdotool windowfocus "$window_id" >/dev/null 2>&1 + xdotool key --window "$window_id" --clearmodifiers alt+F4 >/dev/null 2>&1 + fi + sleep 1 + kill "$code_pid" >/dev/null 2>&1 + pkill -f "$user_data" >/dev/null 2>&1 + if [[ "$keep_temp" != "true" ]]; then + rm -rf "$user_data" "$extensions_dir" + else + echo "Kept temp dirs: $user_data $extensions_dir" >&2 + fi +} +trap cleanup EXIT + +for _ in $(seq 1 60); do + fallback_window_id="" + while IFS= read -r candidate; do + if [[ -z "$candidate" ]]; then + continue + fi + if grep -Fxq "$candidate" <<<"$existing_windows"; then + continue + fi + title="$(xdotool getwindowname "$candidate" 2>/dev/null || true)" + fallback_window_id="${fallback_window_id:-$candidate}" + if [[ "$title" == *"Extension Development Host"* || "$title" == *"$fixture_name"* ]]; then + window_id="$candidate" + break + fi + done < <(search_code_windows) + + window_id="${window_id:-$fallback_window_id}" + [[ -n "$window_id" ]] && break + sleep 1 +done + +if [[ -z "$window_id" ]]; then + echo "No VS Code window found; log follows:" >&2 + sed -n '1,200p' "$log_file" >&2 || true + exit 1 +fi + +width="${window_size%x*}" +height="${window_size#*x}" +if [[ -z "$width" || -z "$height" || "$width" = "$window_size" ]]; then + echo "Invalid --window-size value: $window_size" >&2 + exit 2 +fi + +xdotool windowfocus "$window_id" +xdotool windowsize "$window_id" "$width" "$height" +xdotool windowmove "$window_id" 0 0 + +sleep 8 +xdotool windowfocus "$window_id" +xdotool key --window "$window_id" --clearmodifiers ctrl+shift+p +sleep 1 +xdotool type --window "$window_id" --delay 1 "$command_text" +sleep 1 +xdotool key --window "$window_id" --clearmodifiers Return +sleep 3 +xdotool key --window "$window_id" --clearmodifiers Return +sleep "$wait_seconds" + +import -window "$window_id" "$out_png" +file "$out_png" diff --git a/.build/just/bump-version.ts b/.build/just/bump-version.ts new file mode 100644 index 0000000..8cb38ff --- /dev/null +++ b/.build/just/bump-version.ts @@ -0,0 +1,62 @@ +// Bumps one part of the semantic version in package.json, for `just bump <major|minor|patch>`. +// +// 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 <major|minor|patch> [package.json] [--dry-run] + +import { readFileSync, writeFileSync } from "node:fs"; + +const parts = ["major", "minor", "patch"] as const; +type Part = (typeof parts)[number]; + +const args = process.argv.slice(2); +const dryRun = args.includes("--dry-run"); +const [part, file = "package.json"] = args.filter((arg) => arg !== "--dry-run"); + +if (!parts.includes(part as Part)) { + console.error(`Expected one of ${parts.join(", ")}, not '${part ?? ""}'.`); + process.exit(1); +} + +const text = readFileSync(file, "utf8"); +const versionPattern = /("version"\s*:\s*")([^"]*)(")/; +const match = versionPattern.exec(text); +if (!match) { + console.error(`No "version" field in ${file}.`); + process.exit(1); +} + +const current = match[2]; +const semver = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(current); +if (!semver) { + console.error(`'${current}' in ${file} is not a major.minor.patch version.`); + process.exit(1); +} + +let [major, minor, patch] = semver.slice(1, 4).map(Number); +switch (part as Part) { + case "major": + [major, minor, patch] = [major + 1, 0, 0]; + break; + case "minor": + [minor, patch] = [minor + 1, 0]; + break; + case "patch": + patch += 1; + break; +} + +const next = `${major}.${minor}.${patch}`; +if (dryRun) { + console.log(` ${file}: ${current} -> ${next} (dry run, nothing written)`); + process.exit(0); +} + +writeFileSync(file, text.replace(versionPattern, `$1${next}$3`)); +console.log(` ${file}: ${current} -> ${next}`); diff --git a/.build/just/dotnet.just b/.build/just/dotnet.just new file mode 100644 index 0000000..9507263 --- /dev/null +++ b/.build/just/dotnet.just @@ -0,0 +1,53 @@ +# .NET recipes: tools, restore, build, clean, test and pack, all against one solution. +# +# Override these in the root Justfile. `solution_file` is the one nearly every repository sets; "." +# lets dotnet find the single solution or project in the repository root. + +solution_file := "." +build_configuration := "Debug" +artifacts_folder := "./artifacts" + +# Every test. A TUnit/MTP tree-node filter: /assembly/namespace/class/test. +test_filter := "/*/*/*/*/" + +# Restore the repository's .NET local tools (.config/dotnet-tools.json) +[group('Build and Test')] +ensure-tools: + dotnet tool restore + +# Restore NuGet packages for the solution +[group('Build and Test')] +restore *args: + echo "==> Restoring {{ BLUE }}{{ solution_file }}{{ NORMAL }}" + dotnet restore "{{ solution_file }}" {{ args }} + +# Build the solution with build_configuration (default: Debug) +[group('Build and Test')] +build *args: + echo "==> Building {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ GREEN }}{{ current_version }}{{ NORMAL }}) with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}" + dotnet build "{{ solution_file }}" --configuration "{{ build_configuration }}" {{ args }} + +# Clean the solution with build_configuration (default: Debug) +[group('Build and Test')] +clean *args: + echo "==> Cleaning {{ BLUE }}{{ solution_file }}{{ NORMAL }} with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}" + dotnet clean "{{ solution_file }}" --configuration "{{ build_configuration }}" {{ args }} + +# Run the tests matching a tree-node filter (default: every test) +# +# just test "/*/*/*/*[Category=Unit]" +# just test "/*/*/ProjectHarnessTests/*" +# +# Exit code 8 - "no test ran" - is ignored, so a filter that matches nothing in one test project does +# not fail the run for the others. +[doc('Run the tests matching a tree-node filter (default: every test)')] +[group('Build and Test')] +test filter=test_filter *args: + echo "==> Testing {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ YELLOW }}{{ build_configuration }}{{ NORMAL }}) with filter {{ GREEN }}{{ filter }}{{ NORMAL }}" + dotnet test --project "{{ solution_file }}" --configuration "{{ build_configuration }}" --treenode-filter "{{ filter }}" --ignore-exit-code 8 {{ args }} + +# Pack every packable project into artifacts_folder (default: ./artifacts) +[group('Build and Test')] +pack artifact_folder=artifacts_folder *args: + echo "==> Packing {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ GREEN }}{{ current_version }}{{ NORMAL }}) to {{ YELLOW }}{{ artifact_folder }}{{ NORMAL }}" + dotnet pack "{{ solution_file }}" --configuration "{{ build_configuration }}" --output "{{ artifact_folder }}" {{ args }} diff --git a/.build/just/env.just b/.build/just/env.just new file mode 100644 index 0000000..6156f34 --- /dev/null +++ b/.build/just/env.just @@ -0,0 +1,56 @@ +# The environment every recipe runs in: telemetry opt-outs, and the .NET CLI speed-ups. +# +# `export` hands each value to every process a recipe starts - dotnet, bun, pwsh, git. Each one is a +# default: `env(NAME, value)` keeps whatever the caller's environment already says, so a developer +# who has opted back in, or a CI job that sets its own value, is never overridden. + +# -- Telemetry and tracking -------------------------------------------------------------------------- + +# The .NET CLI, MSBuild and Aspire. +export DOTNET_CLI_TELEMETRY_OPTOUT := env("DOTNET_CLI_TELEMETRY_OPTOUT", "1") +export MSBUILD_TELEMETRY_OPTOUT := env("MSBUILD_TELEMETRY_OPTOUT", "1") +export ASPIRE_CLI_TELEMETRY_OPTOUT := env("ASPIRE_CLI_TELEMETRY_OPTOUT", "true") +export ASPIRE_DASHBOARD_TELEMETRY_OPTOUT := env("ASPIRE_DASHBOARD_TELEMETRY_OPTOUT", "true") + +# The Azure SDKs and the Azure MCP server. +export AZURE_CORE_COLLECT_TELEMETRY := env("AZURE_CORE_COLLECT_TELEMETRY", "0") +export AZURE_MCP_COLLECT_TELEMETRY := env("AZURE_MCP_COLLECT_TELEMETRY", "0") + +# PowerShell, for scripts a recipe runs through pwsh, and Git Credential Manager. +export POWERSHELL_TELEMETRY_OPTOUT := env("POWERSHELL_TELEMETRY_OPTOUT", "1") +export GCM_TELEMETRY_OPTOUT := env("GCM_TELEMETRY_OPTOUT", "1") + +# JavaScript tooling: Next.js, Astro, Yarn, and the funding banners printed by postinstall scripts. +export NEXT_TELEMETRY_DISABLED := env("NEXT_TELEMETRY_DISABLED", "1") +export ASTRO_TELEMETRY_DISABLED := env("ASTRO_TELEMETRY_DISABLED", "1") +export YARN_ENABLE_TELEMETRY := env("YARN_ENABLE_TELEMETRY", "0") +export DISABLE_OPENCOLLECTIVE := env("DISABLE_OPENCOLLECTIVE", "true") + +# Claude Code, when a recipe launches it. +export CLAUDE_CLI_TELEMETRY_OPTOUT := env("CLAUDE_CLI_TELEMETRY_OPTOUT", "1") +export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC := env("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") +export DISABLE_ERROR_REPORTING := env("DISABLE_ERROR_REPORTING", "1") +export DISABLE_TELEMETRY := env("DISABLE_TELEMETRY", "1") + +# The cross-tool conventions, honoured by Bun and many other CLIs. +export DO_NOT_TRACK := env("DO_NOT_TRACK", "1") +export TELEMETRY_DISABLED := env("TELEMETRY_DISABLED", "1") + +# -- .NET CLI speed and noise ------------------------------------------------------------------------ + +# No welcome banner or first-run notice. +export DOTNET_NOLOGO := env("DOTNET_NOLOGO", "1") + +# The NativeAOT CLI host (.NET 11): near-instant `dotnet --version`, `--help` and tool launches, with +# everything else falling back to the managed CLI. On by default; opt out with DOTNET_CLI_ENABLEAOT=false. +export DOTNET_CLI_ENABLEAOT := env("DOTNET_CLI_ENABLEAOT", "true") + +# The MSBuild server (DOTNET_CLI_USE_MSBUILD_SERVER) is deliberately left to the SDK's own default. +# Forcing it on makes every `dotnet build` share one long-lived MSBuild process, and its NuGet SDK +# resolver keeps the settings it first read: a build under a different nuget.config - a test +# harness's throwaway consumer, a second repository - then resolves SDKs with the wrong sources and +# fallback folders, and fails with MSB4242. Set it yourself where every build shares one NuGet +# configuration; `just scrub` shuts the server down. + +# Multithreaded MSBuild (-mt) is deliberately not set here. Purview.BuildSdk bootstraps it into the +# repository's Directory.Build.rsp, so a plain `dotnet build` and an IDE build get it as well. diff --git a/.build/just/index.just b/.build/just/index.just new file mode 100644 index 0000000..1d73bbe --- /dev/null +++ b/.build/just/index.just @@ -0,0 +1,41 @@ +# The barrel for the shared just modules. The root Justfile imports this file, and nothing else: +# +# set allow-duplicate-variables +# set allow-duplicate-recipes +# +# import ".build/just/index.just" +# +# Every module gives its variables a default. The root Justfile overrides one by defining the same +# name - `solution_file := "src/Acme.slnx"` - and overrides a no-op hook such as `_after-bump` the +# same way. Both settings are what let a root definition win; without them a duplicate is an error. +# +# The required modules are imported unconditionally. The optional ones use `import?`, so a module is +# switched on by copying its file into this folder and off by deleting it: this file never changes. +# +# These files are copied verbatim from the purview-buildsdk-justfile skill. Change a recipe there, +# not here, and put anything specific to one repository in its root Justfile. + +# Telemetry opt-outs and the .NET CLI speed-ups, exported to every recipe. +import "env.just" + +# version, bump +import "version.just" + +# ensure-tools, restore, build, clean, test, pack +import "dotnet.just" + +# lint-check, lint-fix, vs, scrub +import "utilities.just" + +# pipeline-pr, pipeline-build, pipeline-tests - local runs of the Purview.Build pipeline +import "purview-build.just" + +# skills-* - the Purview.BuildSdk agent-skill targets +import "skills.just" + +# Optional. pipeline-pack-validate, pipeline-local-release, pipeline-release - for a repository that +# packs NuGet packages. +import? "purview-build-nuget.just" + +# Optional. docs, docs-install, docs-check - for a pm-docs-site project site (purview-dev/pm-skills). +import? "docs.just" diff --git a/.build/just/purview-build-nuget.just b/.build/just/purview-build-nuget.just new file mode 100644 index 0000000..2154876 --- /dev/null +++ b/.build/just/purview-build-nuget.just @@ -0,0 +1,30 @@ +# Optional: Purview.Build pipeline runs that pack, validate and publish NuGet packages. Copy this file +# into the module folder only when the repository packs packages; the barrel imports it if present. +# +# Pack validation reads PackValidation in .config/purview-build.json. Its RequiredContent lists every +# file each package must contain, and a packed file it does not list fails the run - so a new file +# under a package's Sdk/, build/ or buildTransitive/ needs a RequiredContent entry too. + +# Run the pipeline through pack and pack validation (restore, build, lint, tests, pack, validate), publishing nothing +[group('Pipeline')] +pipeline-pack-validate *args: ensure-tools + echo "==> Running the pack and validate pipeline" + {{ pipeline_tool }} --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} + +# Run the release pipeline, publishing to a local NuGet feed (restore, build, lint, tests, pack, validate, publish) +# +# `just` runs recipes through sh, which strips backslashes from unquoted arguments. Set the +# LOCAL_NUGET_FEED_PATH environment variable, or use forward slashes: +# +# just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ +[doc('Run the release pipeline, publishing to a local NuGet feed (restore, build, lint, tests, pack, validate, publish)')] +[group('Pipeline')] +pipeline-local-release *args: ensure-tools + echo "==> Running the local release pipeline" + {{ pipeline_tool }} --Release:Mode=LocalNuGet {{ args }} + +# Run the release pipeline, publishing to NuGet (restore, build, lint, tests, pack, validate, publish, GitHub release) +[group('Pipeline')] +pipeline-release *args: ensure-tools + echo "==> Running the release pipeline" + {{ pipeline_tool }} --Release:Mode=NuGet {{ args }} diff --git a/.build/just/purview-build.just b/.build/just/purview-build.just new file mode 100644 index 0000000..244e44c --- /dev/null +++ b/.build/just/purview-build.just @@ -0,0 +1,24 @@ +# Local runs of the Purview.Build pipeline - the same `dotnet purview-build` tool, and the same +# .config/purview-build.json, that CI runs. Every Build stage - tests, lint, pack and pack validation - +# runs unless that file or an argument turns it off. Anything after the recipe name is passed to the +# tool, so a setting can be overridden for one run: `just pipeline-pr --Build:TestFilter="/*/*/*/*[Category=Unit]"`. + +pipeline_tool := "dotnet purview-build" + +# Run the PR pipeline (restore, build, lint, tests, pack, validate), as configured +[group('Pipeline')] +pipeline-pr *args: ensure-tools + echo "==> Running the PR pipeline" + {{ pipeline_tool }} {{ args }} + +# Run the build pipeline (restore, build, lint, pack, validate), without tests +[group('Pipeline')] +pipeline-build *args: ensure-tools + echo "==> Running the build pipeline" + {{ pipeline_tool }} --Build:RunTests=false --Release:Mode=None {{ args }} + +# Run the pipeline with tests forced on and release off (restore, build, lint, tests, pack, validate) +[group('Pipeline')] +pipeline-tests *args: ensure-tools + echo "==> Running the tests pipeline" + {{ pipeline_tool }} --Build:RunTests=true --Release:Mode=None {{ args }} diff --git a/.build/just/skills.just b/.build/just/skills.just new file mode 100644 index 0000000..0d33902 --- /dev/null +++ b/.build/just/skills.just @@ -0,0 +1,52 @@ +# The Purview.BuildSdk agent-skill targets. Each recipe names one target; the behaviour lives there. +# +# The targets are a thin wrapper over the standard skills CLI (`bun x skills` by default), run from the +# repository root. Every skill target runs once for the whole solution, however many projects it holds, +# so the recipes name the solution, not a hand-picked project. +# +# Every recipe here is run by a person, on purpose. Never make one a dependency of build, test or +# default, or call skills-add, -install, -update or -fix from a git hook: they change the repository, +# and the SDK refuses to run them inside a build (PRSKL0009). skills-check is read-only. +# +# The runner and agents are MSBuild properties (PurviewSkillsRunner, PurviewSkillsAgents), set once in +# Directory.Build.props. Pass a one-off through *args; `dotnet msbuild -p:` splits on `,` and `;`, so +# escape the separator: `just skills-update -p:PurviewSkillsAgents=claude-code%2Ccodex`. + +skills_project := solution_file + +# Install skills from another repository through the skills CLI (append '#<ref>' to pin it) +# +# just skills-add purview-dev/engineering-playbook +[doc("Install skills from another repository through the skills CLI (append '#<ref>' to pin it)")] +[group('Agent Skills')] +skills-add source *args: + echo "==> Installing skills from {{ BLUE }}{{ source }}{{ NORMAL }}" + dotnet msbuild "{{ skills_project }}" -t:InstallPurviewSkills -p:PurviewSkillsSource={{ source }} -v:n {{ args }} + +# Restore every skill skills-lock.json records - how a fresh clone gets the repository's skills back +[group('Agent Skills')] +skills-install *args: + dotnet msbuild "{{ skills_project }}" -t:InstallPurviewSkills -v:n {{ args }} + +# Update the agent skills by re-adding every unique source in skills-lock.json +# +# Each source is re-added at its latest with every skill it has, which refreshes the installed skills +# and adds any a source has gained; `skills update` is never run. FixPurviewSkills then removes links +# from agent directories PurviewSkillsAgents does not select, and does nothing when it is unset. +# The -t: value is quoted because sh would otherwise end the command at the `;`. +# +# just skills-update -p:PurviewSkillsDryRun=true +[doc('Update the agent skills by re-adding every unique source in skills-lock.json')] +[group('Agent Skills')] +skills-update *args: + dotnet msbuild "{{ skills_project }}" "-t:UpdatePurviewSkills;FixPurviewSkills" -v:n {{ args }} + +# Check that only the skill directories of the agents in PurviewSkillsAgents exist +[group('Agent Skills')] +skills-check *args: + dotnet msbuild "{{ skills_project }}" -t:CheckPurviewSkills -v:n {{ args }} + +# Remove the skills CLI's links from agent directories PurviewSkillsAgents does not select +[group('Agent Skills')] +skills-fix *args: + dotnet msbuild "{{ skills_project }}" -t:FixPurviewSkills -v:n {{ args }} diff --git a/.build/just/utilities.just b/.build/just/utilities.just new file mode 100644 index 0000000..d813497 --- /dev/null +++ b/.build/just/utilities.just @@ -0,0 +1,44 @@ +# Formatting, opening the solution, and scrubbing the build output. + +csharpier := "dotnet csharpier" + +# Check formatting with CSharpier +[group('Utilities')] +lint-check: + {{ csharpier }} check . + +# Format the code with CSharpier +[group('Utilities')] +lint-fix: + {{ csharpier }} format . + +# Open the solution in Visual Studio, or whichever application is registered for it +[group('Utilities')] +[windows] +vs: + start "" "{{ solution_file }}" + +# Open the solution in whichever application is registered for it +[group('Utilities')] +[macos] +vs: + open "{{ solution_file }}" + +# Open the solution in whichever application is registered for it +[group('Utilities')] +[linux] +vs: + xdg-open "{{ solution_file }}" + +# Delete every bin/, obj/ and artifacts_folder, stop the build servers, and restore from scratch +# +# The build servers go first: the MSBuild server and the compiler server hold files open under obj/, +# which on Windows stops them being deleted. node_modules/ and .git/ are never searched. +[doc('Delete every bin/, obj/ and artifacts_folder, stop the build servers, and restore from scratch')] +[group('Utilities')] +scrub: + echo "==> Scrubbing build output and stopping the build servers" + dotnet build-server shutdown + find . \( -name node_modules -o -name .git \) -prune -o -type d \( -name bin -o -name obj \) -prune -exec rm -rf {} + + rm -rf "{{ artifacts_folder }}" + just restore --force-evaluate diff --git a/.build/just/version.just b/.build/just/version.just new file mode 100644 index 0000000..09dbb37 --- /dev/null +++ b/.build/just/version.just @@ -0,0 +1,28 @@ +# The package version, from package.json: the single source of truth Purview.BuildSdk versions every +# project and package from. + +version_file := "package.json" + +# Read by just itself rather than by spawning bun, so `just --list` needs no Node tooling. +current_version := if path_exists(version_file) == "true" { replace_regex(read(version_file), '(?s).*?"version"\s*:\s*"([^"]*)".*', '$1') } else { "0.0.0" } + +# Display the current version, from package.json +[group('Version')] +version: + echo "==> Current version: {{ GREEN }}{{ current_version }}{{ NORMAL }} (from {{ version_file }}, stamped into every build by Purview.BuildSdk)" + +# Bump one part of the version in package.json - major, minor or patch - resetting the parts after it +# +# just bump minor # 2.0.1 -> 2.1.0 +# just bump patch --dry-run # print the new version, write nothing +# +# A pre-release suffix is dropped. Requires Bun. Afterwards it runs `_after-bump`, which does nothing +# unless the root Justfile overrides it to refresh whatever else pins the version. +[doc('Bump one part of the version in package.json - major, minor or patch - resetting the parts after it')] +[arg("part", pattern="major|minor|patch")] +[group('Version')] +bump part *args: && _after-bump + bun "{{ source_directory() / 'bump-version.ts' }}" "{{ part }}" "{{ version_file }}" {{ args }} + +# Runs after a successful `bump`. Override it in the root Justfile, e.g. `_after-bump: version-sync`. +_after-bump: diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index bc9bc27..1978504 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,7 +17,7 @@ "rollForward": false }, "purview.build": { - "version": "0.9.0", + "version": "0.9.1", "commands": [ "purview-build" ], diff --git a/.editorconfig b/.editorconfig index a9b39cb..a1e0051 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/AGENTS.md b/AGENTS.md index 2c997fa..dbe12ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,16 @@ Do **not** suppress or mute compiler, analyser, or build warnings by adding `<No ## Documentation and Samples -Any API change must update the corresponding documentation, including XML doc comments and `docs/wiki/` pages such as `docs/wiki/Code-Writer.md`. Samples (the `SourceGeneratorFramework.ExampleGenerator` reference implementation and benchmarks) must use the current best-practice APIs: the minimal-parameter `CodeWriter` overloads, structured statements (`MethodCall`, `Return`, `Assignment`, `Throw`, `Comment`, `NetConditionalReturn`) rather than raw text, and the current method names. Add or update a sample whenever an API addition or change warrants a demonstrable example, and cover it with unit tests. +Any API change must update the corresponding documentation, including XML doc comments and `docs/wiki/` pages such as `docs/wiki/authoring/Code-Writer.md`. Samples (the `SourceGeneratorFramework.ExampleGenerator` reference implementation and benchmarks) must use the current best-practice APIs: the minimal-parameter `CodeWriter` overloads, structured statements (`MethodCall`, `Return`, `Assignment`, `Throw`, `Comment`, `NetConditionalReturn`) rather than raw text, and the current method names. Add or update a sample whenever an API addition or change warrants a demonstrable example, and cover it with unit tests. + +## Justfile + +The root `Justfile` holds only this repository's variables and own recipes. The shared recipes live in +`.build/just/`, copied verbatim from the `purview-buildsdk-justfile` skill: change a shared recipe in +the skill and copy it in, never edit `.build/just/` in place. The overrides in the root file are +deliberate and commented — `test` adds `--results-directory-layout per-module`, `pipeline-local-release` +formats first, and `skills_project` names the main package project because the Benchmarks project +opts out of `Purview.BuildSdk`. ## Installed skills (`.agents/skills/`) @@ -25,10 +34,11 @@ has them without running anything. This repository's own skills in `skills/` are authored for **consumers** of its packages, not for use while working on this repository. They are never installed into this repository's `.agents/skills/`, which holds only skills installed from other repositories; `InstalledSkillTests` enforces this. -Skills are installed into the universal `.agents/skills/` location **only**. Never install copies -into `.claude/` or any other per-agent folder, and never commit a `CLAUDE.md`: this repository is not -agent-specific, and `AGENTS.md` is its one instruction file. `/.claude/` is gitignored for that -reason. Pass `-a universal`, which writes `.agents/skills/` and nothing else: +Skills are installed into the universal `.agents/skills/` location **only**. Never commit a copy into +`.claude/` or any other per-agent folder, and never commit a `CLAUDE.md`: this repository is not +agent-specific, and `AGENTS.md` is its one instruction file. `/.claude/` is gitignored, so a local +folder created by an agent is harmless and is not treated as repository state. Pass `-a universal`, +which writes `.agents/skills/` and nothing else: ```bash # add or update every skill from a source @@ -48,8 +58,8 @@ it is restricted the same way rather than defaulting to every agent (`*`). `src/tests/Repository.UnitTests/InstalledSkillTests.cs` keeps this honest: every `skills-lock.json` entry has its `.agents/skills/<name>/SKILL.md`, every installed folder is in the lockfile, no -authored `skills/` skill is copied into `.agents/skills/`, no `.claude/skills/` copy exists, and no -`CLAUDE.md` is present. +authored `skills/` skill is copied into `.agents/skills/`, `/.claude/` stays gitignored, and no +tracked `CLAUDE.md` is present. ## Authoring skills diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f6cadd..eb26f6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,31 @@ that version to nuget.org. This file was reconstructed from the conventional-commit history at 1.1.1, so entries before that release summarise what the commits recorded rather than what was written at the time. +## [Unreleased] + +### Added + +- **C# 15 construct support in `CodeWriter`.** `TypeDeclarationKind.Union` with + `TypeDeclarationOptions.UnionCaseTypes`, plus `Union`/`UnionScope` overloads, emits a + `union Name(CaseTypes)` declaration (semicolon-terminated or with a body). `IsClosed` emits the + `closed` modifier on a class or record class, suppressing the default `sealed`; `closed` combines + with `partial` and rejects `static`/`abstract`/non-class declarations. Reopening a containing type + with `TypeHelpers.CreatePartialTypeDeclarationOptions` detects the compiler's + `IsClosedTypeAttribute` and preserves `closed` instead of emitting `abstract`. +- Code fixes for the structured-`CodeWriter` suggestions: single-line statements now rewrite to + `Return`, `Throw`, `MethodCall`, `MethodCallOn`, `AwaitedMethodCall`, `AwaitedMethodCallOn`, + `Assignment`, `Comment`, `Using`, or `UsingAlias` (`PSGFR19`); a bare `*DeclarationOptions` + construction moves to the minimal overload (`PSGFR20`); and `#pragma warning disable` rewrites to + `PragmaDisable` (`PSGFR22`). + +### Changed + +- **The structured-API suggestions are warnings.** `PSGFR16` and `PSGFR18`–`PSGFR23`, which recommend a + structured `CodeWriter` API (for example `Return` instead of `Line("return value;")`), and `PSGFR34` + for extension blocks, are now `Warning` rather than `Info`. A build that treats warnings as errors + now fails until the suggestion is adopted or explicitly suppressed. Informational findings such as a + duplicate enum value stay `Info`. + ## [2.0.0] - 2026-11-10 ### Changed @@ -16,7 +41,7 @@ release summarise what the commits recorded rather than what was written at the - **.NET 8 and .NET 9 are out of support.** The libraries still ship a `netstandard2.0` asset, so a .NET 8 or .NET 9 consumer keeps working, but neither runtime is built against or tested. The test matrix is `net10.0` and `net11.0`, and the benchmarks target the same pair. See - [docs/wiki/DotNet-11.md](docs/wiki/DotNet-11.md). + [docs/wiki/platform/DotNet-11.md](docs/wiki/platform/DotNet-11.md). - **The repository requires the .NET 11 SDK** (`11.0.100`, with `allowPrerelease` now `false` because .NET 11 has shipped) and `Purview.BuildSdk` 2.0.0. - **The packaged merge tool supports .NET 10 and .NET 11 from one `tools/` folder.** It targets the @@ -33,7 +58,7 @@ release summarise what the commits recorded rather than what was written at the ### Added -- [docs/wiki/DotNet-11.md](docs/wiki/DotNet-11.md): the support matrix, the .NET 11 features that +- [docs/wiki/platform/DotNet-11.md](docs/wiki/platform/DotNet-11.md): the support matrix, the .NET 11 features that `Purview.BuildSdk` enables for `net11.0` (Runtime Async, C# 15, memory-safety rules, `-mt`), and the features that were evaluated and deliberately left off. It records why no union type was required and the non-boxing `IUnion`/`HasValue`/`TryGetValue` pattern to use if one is added. @@ -170,7 +195,7 @@ release summarise what the commits recorded rather than what was written at the than to the framework: the id is derived from the component's namespace (`Acme.Widgets.Generators` → `AWG0001`), the title from the component kind, the category from the root namespace, and the severity is `Warning`. Every part is overridable through `ComponentFailureOptions`, including an opt-in stack trace. - See [`docs/wiki/Component-Failures.md`](docs/wiki/Component-Failures.md). + See [`docs/wiki/authoring/Component-Failures.md`](docs/wiki/authoring/Component-Failures.md). The framework's own `RegisterSourceOutput` now runs the emit callback through the guard, so a generator built on it is covered without changing a line. Its signature is unchanged — the overrides are declared on @@ -208,7 +233,7 @@ release summarise what the commits recorded rather than what was written at the ### Documentation -- `docs/wiki/Step-Cache-Tests.md` documents the fresh-compilation default and the opt-out. +- `docs/wiki/testing/Step-Cache-Tests.md` documents the fresh-compilation default and the opt-out. ## [1.1.1] diff --git a/Justfile b/Justfile index 488cabd..10720d0 100644 --- a/Justfile +++ b/Justfile @@ -1,151 +1,65 @@ +# Recipes for SourceGeneratorFramework. 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 -export TESTINGPLATFORM_EXITCODE_IGNORE := "8" -export DOTNET_CLI_TELEMETRY_OPTOUT := "1" -export DOTNET_SKIP_FIRST_TIME_EXPERIENCE := "1" -export DO_NOT_TRACK := "1" +# 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" -root_folder := "src" -solution := root_folder / "SourceGeneratorFramework.slnx" -benchmark := root_folder / "src/SourceGeneratorFramework.Benchmarks/SourceGeneratorFramework.Benchmarks.csproj" -build_configuration := "Debug" -artifacts_folder := "./artifacts" -default_test_filter := "/*/*/*/*" +solution_file := "src/SourceGeneratorFramework.slnx" -pipeline_tool := "dotnet purview-build" +benchmark := "src/src/SourceGeneratorFramework.Benchmarks/SourceGeneratorFramework.Benchmarks.csproj" -current_version := `bun -p "require('./package.json').version"` +# The Benchmarks project deliberately opts out of Purview.BuildSdk, because BenchmarkDotNet generates +# child projects under it, so a solution-wide run of an SDK target fails with MSB4057. Point the skill +# targets at the main package project instead: they act on the repository root and run once either way. +skills_project := "src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj" [private] default: just --list -# Restore the repository's .NET local tools (.config/dotnet-tools.json) -[private] -ensure-pipeline-tool: - dotnet tool restore - -# Run the PR pipeline (restore, build, lint, tests) -[group('Pipeline')] -pipeline-pr *args: - 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 - just lint-fix - echo "Running local release pipeline..." - {{ pipeline_tool }} --Release:Mode=LocalNuGet {{ args }} - -# Run the pipeline with tests enabled -[group('Pipeline')] -pipeline-tests *args: - just ensure-pipeline-tool - echo "Running tests pipeline..." - {{ pipeline_tool }} --Build:RunTests=true --Release:Mode=None {{ args }} - -# Run the pipeline through pack + validate (restore, build, lint, tests, pack, validate pack contents) without publishing/releasing -[group('Pipeline')] -pipeline-pack-validate *args: - just ensure-pipeline-tool - echo "Running pack + validate pipeline..." - {{ pipeline_tool }} --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} - -# Build and test with the specified configuration, defaulting to "Debug" -[group('Build and Test')] -build *args: - echo "Building {{ BLUE }}{{ solution }}{{ NORMAL }} with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}" - dotnet build {{ solution }} -c {{ build_configuration }} {{ args }} - -# Run tests with the specified configuration, defaulting to "Debug" +# Kept local rather than taken from dotnet.just: --results-directory-layout per-module stops a +# multi-target test run (net10.0 and net11.0) writing reports with the same relative file name and +# overwriting one another. See docs/wiki/platform/DotNet-11.md. +[doc('Run the tests matching a tree-node filter (default: every test)')] [group('Build and Test')] -test filter=default_test_filter *args: - echo "Running tests for {{ BLUE }}{{ solution }}{{ NORMAL }} with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}" - echo " and filter {{ GREEN }}{{ filter }}{{ NORMAL }}." - dotnet test {{ solution }} -c {{ build_configuration }} --treenode-filter "{{ filter }}" --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 --project "{{ solution_file }}" --configuration "{{ build_configuration }}" --treenode-filter "{{ filter }}" --results-directory-layout per-module --ignore-exit-code 8 {{ args }} -# Run unit tests only +# Run the unit tests only [group('Build and Test')] test-unit *args: just test "/*/*/*/*[Category=Unit]" {{ args }} -# Runs the benchmarks for the project +# Run the BenchmarkDotNet benchmarks for the project [group('Build and Test')] benchmark *args: - echo "Running benchmarks for {{ BLUE }}{{ solution }}{{ NORMAL }}" - dotnet run --project {{ benchmark }} --configuration Release --framework net11.0 --no-build {{ args }} - -# Clean all projects with the specified configuration, defaulting to "Debug" -[group('Build and Test')] -clean *args: - echo "Cleaning {{ BLUE }}{{ solution }}{{ NORMAL }} with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}" - dotnet clean {{ solution }} -c {{ build_configuration }} {{ args }} + echo "==> Running benchmarks for {{ BLUE }}{{ solution_file }}{{ NORMAL }}" + dotnet run --project "{{ benchmark }}" --configuration Release --framework net11.0 --no-build {{ args }} -# Clean all projects, across Debug and Release configurations +# Clean the solution in both Release and Debug [group('Build and Test')] clean-all *args: - echo "Cleaning all projects with configuration" - dotnet clean {{ solution }} -c Release {{ args }} - dotnet clean {{ solution }} -c Debug {{ args }} - -# Run tests with the specified configuration, defaulting to "Debug" -[group('Build and Test')] -restore *args: - echo "Restoring dependencies for {{ BLUE }}{{ solution }}{{ NORMAL }}" - dotnet restore {{ solution }} {{ args }} + echo "==> Cleaning {{ BLUE }}{{ solution_file }}{{ NORMAL }} in Release and Debug" + dotnet clean "{{ solution_file }}" --configuration Release {{ args }} + dotnet clean "{{ solution_file }}" --configuration Debug {{ args }} -# Create NuGet package for the project -[group('Build and Test')] -pack publish_folder=artifacts_folder *args: - echo "Packing {{ BLUE }}{{ solution }}{{ NORMAL }} with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }} to {{ GREEN }}{{ publish_folder }}{{ NORMAL }}" - dotnet pack {{ solution }} -c {{ build_configuration }} -o {{ publish_folder }} {{ args }} - -# Display the current version of the project -[group('Build and Test')] -version: - echo "Current version: {{ GREEN }}{{ current_version }}{{ NORMAL }}" - -# Open the solution in Visual Studio/ Registered application -[group('Utilities')] -vs: - open {{ solution }} - -# Check code formatting using CSharpier -[group('Utilities')] -lint-check: - dotnet csharpier check . - # dotnet format --verify-no-changes {{ solution }} - -# Fix code formatting issues using CSharpier -[group('Utilities')] -lint-fix: - dotnet csharpier format . - # dotnet format {{ solution }} +# Kept local rather than taken from purview-build-nuget.just: formatting runs first, so a local +# release does not fail its lint check on code that only needed reformatting. +[doc('Run the release pipeline, publishing to a local NuGet feed, after formatting the tree')] +[group('Pipeline')] +pipeline-local-release *args: ensure-tools lint-fix + echo "==> Running the local release pipeline" + {{ pipeline_tool }} --Release:Mode=LocalNuGet {{ args }} -# 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 +# Validate the skills authored in skills/ (PRSKL0005), as the pack-time check does +[group('Agent Skills')] +skills-validate *args: + dotnet msbuild "{{ skills_project }}" -t:ValidatePurviewSkills -v:n {{ args }} diff --git a/README.md b/README.md index 0d7af95..f2f6702 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,10 @@ A set of libraries for building and testing incremental C# source generators usi ## Documentation - [Source generator & analyser best practices](docs/wiki/Guide.md) -- [CodeWriter structured API reference](docs/wiki/Code-Writer.md) -- [Step-cache tests for incremental generators](docs/wiki/Step-Cache-Tests.md) -- [Benchmark results](docs/wiki/Performance.md) -- [.NET 11 support and features](docs/wiki/DotNet-11.md) +- [CodeWriter structured API reference](docs/wiki/authoring/Code-Writer.md) +- [Step-cache tests for incremental generators](docs/wiki/testing/Step-Cache-Tests.md) +- [Benchmark results](docs/wiki/operations/Performance.md) +- [.NET 11 support and features](docs/wiki/platform/DotNet-11.md) ## Packages @@ -71,7 +71,7 @@ See the [`Purview.SourceGeneratorFramework.Generators` README](src/src/SourceGen `TypeLibraryGenerator` removes the boilerplate of hand-writing the static type library that exposes the `TypeIdentity`/`TypeReference` values a generator needs. From a small declarative spec (`[GenerateTypeLibrary]` + `[TypeRef]` members), it emits a self-contained `public static partial` type library whose nested `public static partial` classes mirror the namespaces of the members, each with a `Namespace` constant and `public static readonly` fields. -See [docs/wiki/Type-Library.md](docs/wiki/Type-Library.md) for the DSL, the member-accessibility rules, and a runnable sample in [`SourceGeneratorFramework.ExampleGenerator`](src/src/SourceGeneratorFramework.ExampleGenerator/TypeLibrarySpec.cs). +See [docs/wiki/authoring/Type-Library.md](docs/wiki/authoring/Type-Library.md) for the DSL, the member-accessibility rules, and a runnable sample in [`SourceGeneratorFramework.ExampleGenerator`](src/src/SourceGeneratorFramework.ExampleGenerator/TypeLibrarySpec.cs). ## Packaging diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index 323336e..ea1bfe6 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -45,7 +45,7 @@ returns a **merged, self-contained** generator from its intermediate output, so generator's own bin output stays unmerged, so its in-process test harness retains shared framework type identity. Specifying `Targets="GetSourceGeneratorAnalyzerFiles"` explicitly remains supported but is not required. Set `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles` to `false` only when the -unmerged assembly + loose framework DLL shape is required (see [Packaging.md](Packaging.md)). +unmerged assembly + loose framework DLL shape is required (see [Packaging.md](operations/Packaging.md)). ### A component that references another component @@ -57,7 +57,7 @@ resolution copies it beside the dependent component's bin output, so that folder A project that references a component only as an analyzer never receives it. The dependent component's analyzer closure includes the referenced component's merged artifact — it is never IL-merged a second time, which would duplicate its types. See -[Packaging.md](Packaging.md#a-component-that-references-another-component). +[Packaging.md](operations/Packaging.md#a-component-that-references-another-component). Declare each MSBuild property the generator reads with `<PurviewGeneratorVisibleProperty Include="MyGenerator_Disable" />`; `PSGF0003` fails the build when @@ -198,9 +198,9 @@ dotnet add package Purview.SourceGeneratorFramework.Testing.TUnit ## Next pages - [Source Generator & Analyser Best Practices](Guide.md) -- [CodeWriter structured API reference](Code-Writer.md) -- [Incremental Pipeline](Incremental-Pipeline.md) +- [CodeWriter structured API reference](authoring/Code-Writer.md) +- [Incremental Pipeline](authoring/Incremental-Pipeline.md) - [Testing](Testing.md) -- [Testing with TUnit](Testing-TUnit.md) -- [Step-Cache Tests](Step-Cache-Tests.md) -- [Packaging](Packaging.md) +- [Testing with TUnit](testing/Testing-TUnit.md) +- [Step-Cache Tests](testing/Step-Cache-Tests.md) +- [Packaging](operations/Packaging.md) diff --git a/docs/wiki/Guide.md b/docs/wiki/Guide.md index 6f2e0f8..f466d9c 100644 --- a/docs/wiki/Guide.md +++ b/docs/wiki/Guide.md @@ -29,7 +29,7 @@ --- -# 1. Core Principles +## 1. Core Principles The most important rules are: @@ -50,7 +50,7 @@ The guiding principle for an incremental generator is: --- -# 2. Analyser or Source Generator? +## 2. Analyser or Source Generator? Analysers and generators have different responsibilities. @@ -62,7 +62,7 @@ An `IIncrementalGenerator` answers: > Given valid source code, what source should be generated? -## Decision Table +### Decision Table | Requirement | Prefer | Reason | | --- | --- | --- | @@ -104,13 +104,13 @@ A useful shorthand is: --- -# 3. Choosing an Analyser Action +## 3. Choosing an Analyser Action Use the narrowest analyser API that directly represents the thing being analysed. Do not start with a broad syntax scan if Roslyn already exposes the concept as a symbol or operation. -## Analyser Action Decision Matrix +### Analyser Action Decision Matrix | API | Use when | Examples | Recommendation | | --- | --- | --- | --- | @@ -131,7 +131,7 @@ Do not start with a broad syntax scan if Roslyn already exposes the concept as a --- -# 4. Syntax vs Symbol vs Operation +## 4. Syntax vs Symbol vs Operation The most common analyser design decision is choosing between: @@ -139,7 +139,7 @@ The most common analyser design decision is choosing between: - symbols; - operations. -## Quick Decision +### Quick Decision ```text Does exact source spelling/structure matter? @@ -157,7 +157,7 @@ Does exact source spelling/structure matter? --- -## Syntax +### Syntax Use syntax when the literal structure of the user's source matters. @@ -184,7 +184,7 @@ Do not ask the semantic model a question that can be answered directly from synt --- -## Symbols +### Symbols Use symbols when analysing declarations semantically. @@ -217,7 +217,7 @@ should normally be used rather than reference equality. --- -## Operations +### Operations Use `IOperation` when analysing executable semantics. @@ -275,7 +275,7 @@ for every invocation. --- -## Common Operation Kinds +### Common Operation Kinds | Requirement | Operation | | --- | --- | @@ -296,9 +296,9 @@ Use the semantic operation rather than reconstructing equivalent information fro --- -# 5. Analyser Best Practices +## 5. Analyser Best Practices -## Enable Concurrent Execution +### Enable Concurrent Execution Analysers should normally enable concurrent execution: @@ -321,7 +321,7 @@ Avoid shared mutable state. --- -## Configure Generated Code Explicitly +### Configure Generated Code Explicitly Do not leave generated-code handling implicit. @@ -339,7 +339,7 @@ Only inspect generated code if the analyser explicitly needs to. --- -## Resolve Known Types Once +### Resolve Known Types Once If an analyser needs to repeatedly compare against known framework or library types, resolve them during compilation start. @@ -367,7 +367,7 @@ Do not use `CompilationStart` merely to enumerate the entire compilation. --- -## Prefer Narrow Registration +### Prefer Narrow Registration Prefer: @@ -393,7 +393,7 @@ over scanning an entire `SyntaxTree`. --- -# 6. Incremental Generator Golden Rules +## 6. Incremental Generator Golden Rules Implement: @@ -411,7 +411,7 @@ But simply implementing `IIncrementalGenerator` does **not** make a generator me Incrementally depends on the equality behaviour of the values flowing through the pipeline. -## The Golden Rule +### The Golden Rule > **Pipeline values must be immutable and value-equatable.** @@ -425,9 +425,9 @@ If the answer is yes, Roslyn can stop executing downstream stages and reuse cach --- -# 7. Pipeline Value Equality +## 7. Pipeline Value Equality -## Pipeline Red List +### Pipeline Red List These should not normally survive into your generator model. @@ -451,7 +451,7 @@ These should not normally survive into your generator model. --- -## Good Model +### Good Model ```csharp internal sealed record TypeModel( @@ -471,7 +471,7 @@ internal sealed record PropertyModel( --- -## Bad Model +### Bad Model ```csharp internal sealed record TypeModel( @@ -486,7 +486,7 @@ Making the outer object a `record` does not magically make its members suitable --- -## `ImmutableArray<T>` Is Not Enough +### `ImmutableArray<T>` Is Not Enough `ImmutableArray<T>` solves: @@ -550,49 +550,37 @@ same contents => equal pipeline value --- -# 8. Designing the Incremental Pipeline +## 8. Designing the Incremental Pipeline Think of every transformation as a cache checkpoint. Prefer: -```text -Roslyn Input - │ - ▼ -Cheap discovery - │ - ▼ -Semantic extraction - │ - ▼ -Small equatable model - │ - ▼ -Validation/transformation - │ - ▼ -Generation model - │ - ▼ -Source output +<!-- purview:likec4 file="./diagrams/incremental-pipeline-preferred.c4" view="preferredPipeline" title="Preferred incremental pipeline" --> +```mermaid +flowchart LR + input[Roslyn input] --> discovery[Cheap discovery] + discovery --> extraction[Semantic extraction] + extraction --> equatable[Small equatable model] + equatable --> validation[Validation / transformation] + validation --> generation[Generation model] + generation --> output[Source output] ``` +[LikeC4 model](./diagrams/incremental-pipeline-preferred.c4) +<!-- /purview:likec4 --> + Do not do: -```text -Roslyn Input - │ - ▼ -Giant transform containing symbols + syntax + compilation - │ - ▼ -Generate everything +```mermaid +flowchart LR + input[Roslyn input] --> giant[Giant transform<br/>symbols + syntax + compilation] + giant --> generate[Generate everything] ``` --- -## Project Early +### Project Early The semantic transform should usually be the boundary where Roslyn objects disappear. @@ -628,7 +616,7 @@ Everything downstream should receive `TypeModel`, not `INamedTypeSymbol`. --- -## Prefer Static Lambdas +### Prefer Static Lambdas Prefer: @@ -645,7 +633,7 @@ Generator instances should not be treated as application services or state conta --- -## Honour Cancellation +### Honour Cancellation For non-trivial transformations: @@ -662,41 +650,29 @@ Pass cancellation tokens into Roslyn APIs that accept them. --- -## Split Transformations +### Split Transformations Prefer: -```text -Syntax - ↓ -Symbol projection - ↓ -Type model - ↓ -Property models - ↓ -Generation model - ↓ -Output +```mermaid +flowchart LR + syntax[Syntax] --> symbol[Symbol projection] --> type[Type model] --> property[Property models] --> generation[Generation model] --> output[Output] ``` over: -```text -Syntax - ↓ -Do absolutely everything - ↓ -Output +```mermaid +flowchart LR + syntax[Syntax] --> everything[Do absolutely everything] --> output[Output] ``` More meaningful boundaries give Roslyn more opportunities to short-circuit downstream processing. --- -# 9. Syntax Discovery +## 9. Syntax Discovery -## Prefer `ForAttributeWithMetadataName` +### Prefer `ForAttributeWithMetadataName` Attribute-driven generation should normally start with: @@ -728,7 +704,7 @@ For marker-attribute generators, this should be the default. --- -## Use `CreateSyntaxProvider` When Syntax Is Actually the Trigger +### Use `CreateSyntaxProvider` When Syntax Is Actually the Trigger Use: @@ -777,7 +753,7 @@ Semantic work belongs in the transformation callback. --- -## Avoid Indirect Discovery +### Avoid Indirect Discovery Avoid designs that require discovering: @@ -805,9 +781,9 @@ Generate everything somewhere downstream of IBaseSchemaThing --- -# 10. `Collect`, `Combine`, and Invalidation +## 10. `Collect`, `Combine`, and Invalidation -## `Collect()` +### `Collect()` `Collect()` transforms: @@ -833,17 +809,19 @@ C ──► output C After collection: -```text -A ─┐ -B ─┼──► [A,B,C] ──► output -C ─┘ +```mermaid +flowchart LR + a[A] --> aggregate["A,B,C"] + b[B] --> aggregate + c[C] --> aggregate + aggregate --> output[output] ``` Changing `B` changes the aggregate `[A,B,C]`. --- -## Prefer Per-Item Output +### Prefer Per-Item Output Prefer: @@ -872,7 +850,7 @@ unless the generation genuinely requires the complete set. --- -## Good Uses of `Collect()` +### Good Uses of `Collect()` Use `Collect()` when generating something intrinsically global: @@ -884,21 +862,18 @@ Use `Collect()` when generating something intrinsically global: A useful design is: -```text - ┌──► Per-type source -Type Models ────────┤ - │ - └──► Collect() - │ - ▼ - Global registry +```mermaid +flowchart LR + models[Type models] --> perType[Per-type source] + models --> collect["Collect()"] + collect --> registry[Global registry] ``` Only the registry should pay the global invalidation cost. --- -## `Combine()` +### `Combine()` Use `Combine()` when one output logically depends on two providers. @@ -911,17 +886,17 @@ var generationInput = That means: -```text -type changed ───────┐ - ├──► generation invalidated -option changed ─────┘ +```mermaid +flowchart LR + type[Type changed] --> invalidated[Generation invalidated] + option[Option changed] --> invalidated ``` This is correct if either input should regenerate the output. --- -## Be Very Careful Combining `CompilationProvider` +### Be Very Careful Combining `CompilationProvider` This: @@ -958,7 +933,7 @@ At least downstream equality can now short-circuit when the relevant capability --- -## `WithComparer()` +### `WithComparer()` Roslyn provides: @@ -995,9 +970,9 @@ The better solution is usually to redesign `Model`. --- -# 11. Diagnostics +## 11. Diagnostics -## Prefer a Separate Analyser +### Prefer a Separate Analyser Normal user validation belongs in a `DiagnosticAnalyser`. @@ -1011,7 +986,7 @@ Benefits include: --- -## Generator Diagnostics Are Still Valid +### Generator Diagnostics Are Still Valid Generator diagnostics make sense for things such as: @@ -1024,7 +999,7 @@ Do not turn the generator pipeline into an analyser pipeline by default. --- -## Blocking vs Non-Blocking Generator Diagnostics +### Blocking vs Non-Blocking Generator Diagnostics `GeneratorResult<T>.ShouldProcess` decides whether the output stage runs for a target. It is `true` when the result carries a value and none of its carried `ReportableDiagnostic` diagnostics are blocking. @@ -1052,7 +1027,7 @@ non-blocking diagnostics when the partial output is still useful. --- -## Location Handling +### Location Handling Analysers should report diagnostics on the most useful user-authored `Location`. @@ -1072,9 +1047,9 @@ But even this should only be carried downstream if generation actually depends o --- -# 12. Output Generation +## 12. Output Generation -## Output Must Be Deterministic +### Output Must Be Deterministic For the same generator model: @@ -1096,7 +1071,7 @@ Avoid: --- -## Deterministic Hint Names +### Deterministic Hint Names Good: @@ -1124,7 +1099,7 @@ Hint names must be: --- -## Prefer Text Generation +### Prefer Text Generation Do not build a complete Roslyn syntax tree merely to generate source unless there is a strong reason. @@ -1147,7 +1122,7 @@ older hosts. --- -## Post-Initialization Output +### Post-Initialization Output Use: @@ -1195,7 +1170,7 @@ context.RegisterPostInitializationOutput( --- -# 13. Testing Incrementally +## 13. Testing Incrementally Snapshot-testing generated source is not sufficient. @@ -1211,7 +1186,7 @@ Incrementally --- -## Test Cases +### Test Cases At minimum test: @@ -1228,7 +1203,7 @@ At minimum test: --- -## Track Incremental Generator Steps +### Track Incremental Generator Steps Create the generator driver with tracking enabled. @@ -1261,7 +1236,7 @@ The important point is that tests should prove: > An unrelated edit does not rerun expensive downstream generation. -### Framework support +#### Framework support The framework's testing packages make step-cache tests first-class. `SourceGeneratorTestRunner.RunIncrementalAsync` runs one shared driver over a sequence of source sets with step tracking enabled, and every pipeline helper @@ -1284,12 +1259,12 @@ await Assert.That(result.Runs[1]).StepIsModified("GetGenerationConfiguration"); Assertions on `IncrementalCacheRun` (`AllStepsNew`, `AllStepsCachedOrUnchanged`, `StepIsCached`, `StepIsModified`, `HasStepReason`) plus `GetStepReasons()` cover the golden matrix. See -[Step-Cache-Tests.md](Step-Cache-Tests.md) for the full walkthrough and the canonical +[Step-Cache-Tests.md](testing/Step-Cache-Tests.md) for the full walkthrough and the canonical `StepCacheTests.cs` sample in the ExampleGenerator unit tests. --- -# 14. Roslyn Version Compatibility +## 14. Roslyn Version Compatibility The most important packaging rule is: @@ -1307,7 +1282,7 @@ Analyser/generator code executes inside a compiler/IDE host. --- -## Roslyn / Visual Studio Compatibility +### Roslyn / Visual Studio Compatibility Microsoft's published compatibility baseline is: @@ -1350,7 +1325,7 @@ That is incorrect. --- -## Example +### Example A project may target: @@ -1380,9 +1355,9 @@ The compiler host did. --- -# 15. Visual Studio, .NET SDK, and Rider +## 15. Visual Studio, .NET SDK, and Rider -## Safe Roslyn Baselines +### Safe Roslyn Baselines Choose the oldest Roslyn version containing the APIs you require. @@ -1401,7 +1376,7 @@ If you compile against a later package, you have deliberately raised your minimu --- -## .NET SDK +### .NET SDK The .NET SDK contains a compiler toolchain. @@ -1436,7 +1411,7 @@ The relevant variable is the compiler host. --- -## Rider +### Rider Rider supports: @@ -1472,11 +1447,11 @@ Build-time compiler compatibility and Rider IDE integration should be tested ind --- -# 16. Multi-Version Roslyn Packaging +## 16. Multi-Version Roslyn Packaging This area is frequently misunderstood. -## NuGet Analyser Assets Are Not Normal TFM Assets +### NuGet Analyser Assets Are Not Normal TFM Assets Normal runtime/library assets support selection such as: @@ -1509,9 +1484,9 @@ Do not place multiple Roslyn-targeted implementations into the ordinary analyser --- -## Strategy 1 — One Conservative Binary +### Strategy 1 — One Conservative Binary -### Recommended Default +#### Recommended Default Compile against the oldest Roslyn version required by your implementation. @@ -1549,7 +1524,7 @@ For most public generators, this is the correct approach. --- -## Strategy 2 — Raise the Package Baseline +### Strategy 2 — Raise the Package Baseline If a newer Roslyn feature materially improves the generator, it may be better to explicitly raise the minimum compiler version. @@ -1569,7 +1544,7 @@ This is much easier for users to reason about than hidden runtime selection. --- -## Strategy 3 — Separate Packages +### Strategy 3 — Separate Packages For significantly different implementations: @@ -1594,7 +1569,7 @@ Disadvantages: --- -## Strategy 4 — MSBuild-Selected Binary +### Strategy 4 — MSBuild-Selected Binary Advanced packages can store binaries outside the automatically discovered analyser directory: @@ -1660,7 +1635,7 @@ Design-time builds, Visual Studio, Rider, CI, and explicit compiler toolsets all --- -## Multi-Targeting the Generator Is Not Automatic Selection +### Multi-Targeting the Generator Is Not Automatic Selection This: @@ -1685,7 +1660,7 @@ Building multiple binaries and selecting analyser assets are separate problems. --- -## Recommended Rule +### Recommended Rule Unless there is a compelling requirement: @@ -1695,7 +1670,7 @@ This remains the most robust distribution strategy. --- -# 17. `Microsoft.CodeAnalysis.Analysers` +## 17. `Microsoft.CodeAnalysis.Analysers` Do not confuse: @@ -1713,7 +1688,7 @@ They serve different purposes. --- -## `Microsoft.CodeAnalysis.CSharp` +### `Microsoft.CodeAnalysis.CSharp` Provides Roslyn compiler APIs used to implement your analyser/generator. @@ -1730,7 +1705,7 @@ IOperation --- -## `Microsoft.CodeAnalysis.Analysers` +### `Microsoft.CodeAnalysis.Analysers` This is a **meta-analyser package**. @@ -1742,7 +1717,7 @@ It does not define your source-generator API baseline. --- -## Current Package Version +### Current Package Version As of August 2026, the current stable package is: @@ -1786,7 +1761,7 @@ Microsoft.CodeAnalysis.Analysers --- -## Transitive Availability +### Transitive Availability Roslyn compiler packages already bring `Microsoft.CodeAnalysis.Analysers` into the dependency graph as development tooling. @@ -1799,7 +1774,7 @@ You may nevertheless explicitly reference it when: --- -## `PrivateAssets="all"` +### `PrivateAssets="all"` Roslyn development dependencies should normally use: @@ -1825,7 +1800,7 @@ Your consumer should not gain ordinary runtime Roslyn package dependencies simpl --- -## `EnforceExtendedAnalyserRules` +### `EnforceExtendedAnalyserRules` Analyser and generator projects should normally enable: @@ -1843,7 +1818,7 @@ First determine what compiler-host invariant the rule is protecting. --- -## RS1035 +### RS1035 `RS1035` bans APIs considered inappropriate for analysers. @@ -1863,7 +1838,7 @@ Configuration should normally arrive through explicit compiler inputs such as: --- -## RS2008 +### RS2008 `RS2008` relates to analyser diagnostic release tracking. @@ -1880,7 +1855,7 @@ Diagnostic IDs are effectively part of your public API. --- -## Treat Diagnostic Descriptors as Public Contracts +### Treat Diagnostic Descriptors as Public Contracts Changing: @@ -1912,7 +1887,7 @@ should be treated as a compatibility decision. --- -# 18. Recommended Project Configuration +## 18. Recommended Project Configuration A broadly-compatible generator project might start with: @@ -1973,7 +1948,7 @@ The exact Roslyn baseline is a product-support decision. --- -## ProjectReference During Development +### ProjectReference During Development A consuming project can reference the generator as: @@ -1986,7 +1961,7 @@ A consuming project can reference the generator as: --- -## Separate Runtime Contracts From Compiler Tooling +### Separate Runtime Contracts From Compiler Tooling Prefer: @@ -2016,7 +1991,7 @@ This prevents Roslyn dependencies leaking into runtime package assets. --- -## Roslyn Component Discovery +### Roslyn Component Discovery The compiler host only loads a source generator, diagnostic analyser, or code fix provider when three conditions hold. Missing any one means the component is **silently ignored**, which is why @@ -2038,7 +2013,7 @@ restart Visual Studio or reload the project for the fixes to appear. --- -# 19. Extension Class Conventions +## 19. Extension Class Conventions Extension classes should form a coherent, discoverable shape so that "which type does this extend?" and "where does it live?" are answerable from the file path alone. @@ -2070,9 +2045,9 @@ moves the class under `Extensions/{ReceiverNamespace}/` and updates referencing --- -# 20. Review Checklist +## 20. Review Checklist -## Analyser +### Analyser - [ ] Is this rule actually validation rather than generation? - [ ] Am I using the narrowest appropriate analyser action? @@ -2091,7 +2066,7 @@ moves the class under `Extensions/{ReceiverNamespace}/` and updates referencing --- -## Incremental Generator +### Incremental Generator - [ ] Uses `IIncrementalGenerator`. - [ ] Uses `ForAttributeWithMetadataName` where appropriate. @@ -2124,7 +2099,7 @@ moves the class under `Extensions/{ReceiverNamespace}/` and updates referencing --- -## Packaging +### Packaging - [ ] Generator/analyser binaries are packed as analyser assets. - [ ] Compiler tooling is not accidentally shipped as runtime `lib` output. @@ -2142,7 +2117,7 @@ moves the class under `Extensions/{ReceiverNamespace}/` and updates referencing --- -# Summary +## Summary The shortest version of this guide is: diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 8828f3f..2317e62 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -10,18 +10,18 @@ analysers that guide generators back to best practice. - [Getting Started](Getting-Started.md) - [Source Generator & Analyser Best Practices](Guide.md) -- [CodeWriter structured API reference](Code-Writer.md) -- [TypeLibraryGenerator](Type-Library.md) -- [Attribute Data Models](Attribute-Data-Models.md) -- [Incremental Pipeline](Incremental-Pipeline.md) -- [Analyzers](Analyzers.md) +- [CodeWriter structured API reference](authoring/Code-Writer.md) +- [TypeLibraryGenerator](authoring/Type-Library.md) +- [Attribute Data Models](authoring/Attribute-Data-Models.md) +- [Incremental Pipeline](authoring/Incremental-Pipeline.md) +- [Analyzers](authoring/Analyzers.md) - [Testing](Testing.md) -- [Testing with TUnit](Testing-TUnit.md) -- [Step-Cache Tests](Step-Cache-Tests.md) -- [Packaging](Packaging.md) -- [Performance](Performance.md) -- [.NET 11 support and features](DotNet-11.md) -- [Release Flow](Release-Flow.md) +- [Testing with TUnit](testing/Testing-TUnit.md) +- [Step-Cache Tests](testing/Step-Cache-Tests.md) +- [Packaging](operations/Packaging.md) +- [Performance](operations/Performance.md) +- [.NET 11 support and features](platform/DotNet-11.md) +- [Release Flow](operations/Release-Flow.md) ## Packages @@ -38,26 +38,26 @@ analysers that guide generators back to best practice. - **`CodeWriter`** — an allocation-conscious writer for generated C# source with indentation, namespace/type declarations, structured statements, XML documentation, conditional compilation blocks, and deterministic output. Declarations and statements are structured values rather than raw - text; see [Code-Writer.md](Code-Writer.md). + text; see [Code-Writer.md](authoring/Code-Writer.md). - **`IncrementalPipeline`** — extension methods for composing `IncrementalValueProvider<T>` / `IncrementalValuesProvider<T>` pipelines, including attribute-based discovery, generation-context - creation, and disable-property checks; see [Incremental-Pipeline.md](Incremental-Pipeline.md). + creation, and disable-property checks; see [Incremental-Pipeline.md](authoring/Incremental-Pipeline.md). - **`GenerationContext`** — a base execution-services context carrying the Roslyn `Compilation`, immutable generator settings, optional logging, and a factory for independently owned `CodeWriter` instances. - **`GeneratorResult<T>`** — a value-or-diagnostics result type for incremental transforms, with explicit per-diagnostic `IsBlocking` control over whether generation continues. - **`AttributeDataModelGenerator`** — bundled generator that emits `readonly record struct` attribute - parser models from `[Generate]` declarations; see [Attribute-Data-Models.md](Attribute-Data-Models.md). + parser models from `[Generate]` declarations; see [Attribute-Data-Models.md](authoring/Attribute-Data-Models.md). - **`TypeLibraryGenerator`** — generates a self-contained `public static partial` type library from a - small declarative spec; see [Type-Library.md](Type-Library.md). + small declarative spec; see [Type-Library.md](authoring/Type-Library.md). - **Bundled analysers and code fixes** — `PSGFR11`–`PSGFR38` diagnostics for Roslyn best practice, - plus code fixes; see [Analyzers.md](Analyzers.md). + plus code fixes; see [Analyzers.md](authoring/Analyzers.md). - **Testing framework** — `SourceGeneratorTestRunner<TGenerator>`, `CodeQuery` syntax-node assertions, refactoring tests, and incremental cache tests; see [Testing.md](Testing.md) and - [Testing-TUnit.md](Testing-TUnit.md). + [Testing-TUnit.md](testing/Testing-TUnit.md). - **Step-cache tests** — prove a generator caches correctly stage-by-stage; see - [Step-Cache-Tests.md](Step-Cache-Tests.md). + [Step-Cache-Tests.md](testing/Step-Cache-Tests.md). ## Requirements @@ -68,7 +68,7 @@ analysers that guide generators back to best practice. - Source generators target `netstandard2.0`; test projects target `net10.0` and `net11.0`. .NET 8 and .NET 9 are out of support. - The packaged merge tool targets `net10.0` and rolls forward, so it runs on the .NET 10 or .NET 11 - runtime while a component is built. See [.NET 11 support and features](DotNet-11.md). + runtime while a component is built. See [.NET 11 support and features](platform/DotNet-11.md). ## Repository layout @@ -81,4 +81,4 @@ analysers that guide generators back to best practice. - `src/src/SourceGeneratorFramework.Testing.TUnit` — TUnit testing integration. - `src/src/SourceGeneratorFramework.ExampleGenerator` — reference generator implementation. - `src/src/SourceGeneratorFramework.Benchmarks` — BenchmarkDotNet benchmarks (see - [Performance.md](Performance.md)). \ No newline at end of file + [Performance.md](operations/Performance.md)). \ No newline at end of file diff --git a/docs/wiki/Testing.md b/docs/wiki/Testing.md index bbecac4..0f89891 100644 --- a/docs/wiki/Testing.md +++ b/docs/wiki/Testing.md @@ -157,7 +157,7 @@ emitted assembly: inspect types, members and attributes without loading it into executing any code. They are created lazily on first access. Dispose the result (or its `DriverRunResult`) to unload the collectible context and release the metadata view. -[`PSGFR42`](Analyzers.md#rule-reference) enforces that: a `DriverRunResult` that is produced and never +[`PSGFR42`](authoring/Analyzers.md#rule-reference) enforces that: a `DriverRunResult` that is produced and never disposed — including the indirect case where a helper creates a result, projects a value out of it and returns only the projection — is flagged, as a `warning` when the run syntactically compiles to an assembly and as `info` otherwise. `DisposeTestResultCodeFixProvider` inserts the `using`. Awaiting an assertion, or @@ -233,7 +233,7 @@ Scoped results also expose node-inspection checks through `MemberQueryExtensions Tests asserting a nullable expected type can use the test-only `query.MakeNullable(type)` extension on a `CodeQuery` (it accepts a `TypeReference` or `TypeIdentity`). It resolves the annotation against the query's compilation and, unlike `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not -trigger the `PSGFR16` context-overload suggestion — tests have no generation context to pass. +trigger the `PSGFR16` context-overload warning — tests have no generation context to pass. ```csharp var query = result.Generated(); @@ -323,7 +323,7 @@ Reference cache tests live in the `Purview.SourceGeneratorFramework` source repo `SourceGeneratorFramework.ExampleGenerator.UnitTests/StepCacheTests` (the canonical golden-matrix sample), and `.../ServiceRegistrationCacheTests` (an end-to-end generator) — and should be replicated into your own test project rather than copied from the package. See -[Step-Cache-Tests.md](Step-Cache-Tests.md) for the full walkthrough. +[Step-Cache-Tests.md](testing/Step-Cache-Tests.md) for the full walkthrough. ## License diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index d23f6e3..842a456 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -1,14 +1,30 @@ +## Start here + - [Home](Home.md) - [Getting Started](Getting-Started.md) - [Source Generator & Analyser Best Practices](Guide.md) -- [CodeWriter](Code-Writer.md) -- [TypeLibraryGenerator](Type-Library.md) -- [Attribute Data Models](Attribute-Data-Models.md) -- [Incremental Pipeline](Incremental-Pipeline.md) -- [Analyzers](Analyzers.md) + +## Authoring + +- [CodeWriter](authoring/Code-Writer.md) +- [TypeLibraryGenerator](authoring/Type-Library.md) +- [Attribute Data Models](authoring/Attribute-Data-Models.md) +- [Incremental Pipeline](authoring/Incremental-Pipeline.md) +- [Analyzers](authoring/Analyzers.md) +- [Component Failures](authoring/Component-Failures.md) + +## Testing + - [Testing](Testing.md) -- [Testing with TUnit](Testing-TUnit.md) -- [Step-Cache Tests](Step-Cache-Tests.md) -- [Packaging](Packaging.md) -- [Performance](Performance.md) -- [Release Flow](Release-Flow.md) \ No newline at end of file +- [Testing with TUnit](testing/Testing-TUnit.md) +- [Step-Cache Tests](testing/Step-Cache-Tests.md) + +## Operations + +- [Packaging](operations/Packaging.md) +- [Release Flow](operations/Release-Flow.md) +- [Performance](operations/Performance.md) + +## Platform + +- [.NET 11 Support](platform/DotNet-11.md) diff --git a/docs/wiki/_redirects.yml b/docs/wiki/_redirects.yml new file mode 100644 index 0000000..04b90a1 --- /dev/null +++ b/docs/wiki/_redirects.yml @@ -0,0 +1,17 @@ +# previous: current — keep old slugs working after the docs reorganisation. +redirects: + code-writer: authoring/code-writer + attribute-data-models: authoring/attribute-data-models + type-library: authoring/type-library + incremental-pipeline: authoring/incremental-pipeline + analyzers: authoring/analyzers + component-failures: authoring/component-failures + + testing-tunit: testing/testing-tunit + step-cache-tests: testing/step-cache-tests + + packaging: operations/packaging + performance: operations/performance + release-flow: operations/release-flow + + dotnet-11: platform/dotnet-11 diff --git a/docs/wiki/Analyzers.md b/docs/wiki/authoring/Analyzers.md similarity index 88% rename from docs/wiki/Analyzers.md rename to docs/wiki/authoring/Analyzers.md index 43b232b..63df6ba 100644 --- a/docs/wiki/Analyzers.md +++ b/docs/wiki/authoring/Analyzers.md @@ -41,6 +41,12 @@ Opt out with `PurviewSourceGeneratorFrameworkAnalyzerValidation=false` (`PRSGD00 ## Rule reference +The `CodeWriter` style rules that recommend a structured API (`PSGFR16`, `PSGFR18`–`PSGFR23`, and +`PSGFR34`) are reported as **warnings**, so a build that treats warnings as errors fails until the +suggestion is adopted or explicitly suppressed. The remaining `info` rules do not describe a +replacement API; they are lower-confidence or context-dependent findings, such as a duplicate enum +value, or `PSGFR42` for a run that does not compile to an assembly. + | Rule | Summary | |------|---------| | `PSGFR11` | Prefer `SyntaxProvider.ForAttributeWithMetadataName` over `CreateSyntaxProvider` for attribute-based detection. | @@ -107,7 +113,21 @@ analyzer rules above, including: equality (`PSGFR15`). - `PreferStructuredCodeWriterIfBlockCodeFixProvider` — rewrites raw `if`/`else if`/`else` block text to the structured `IfBlock`/`ElseIf`/`Else` APIs (`PSGFR23`). +- `PreferStructuredCodeWriterStatementCodeFixProvider` — rewrites a raw single-line statement to + `Return`, `Throw`, `MethodCall`, `MethodCallOn`, `AwaitedMethodCall`, `AwaitedMethodCallOn`, + `Assignment`, `Comment`, `Using`, or `UsingAlias` (`PSGFR19`). A block header such as + `Line("if (enabled)")` has no fix here because the raw text does not describe the body. +- `PreferMinimalCodeWriterOverloadCodeFixProvider` — moves a bare `*DeclarationOptions` constructor's + arguments into the minimal overload (`PSGFR20`). +- `PreferPragmaDisableCodeFixProvider` — rewrites a raw `#pragma warning disable` line to + `PragmaDisable` (`PSGFR22`). A raw `restore` is left unchanged because the structured API owns the + restore through `OpenPragmasScope`. - `CodeWriterToStringCodeFixProvider` — replaces embedded `CodeWriter` string interpolation (`PSGFR29`). + +`PSGFR18` and `PSGFR21` have no automatic fix: a raw declaration fragment, and a `#if`/`#else`/`#endif` +region split across sibling statements, do not carry enough structure to reconstruct a structured call +without guessing. The other `CodeWriter` warnings are fixable, so a "Fix all" run leaves only those two +for manual review. - `PreferInlineCodeForFrameworkCrefCodeFixProvider` — rewrites SGF XML doc `cref` targets to inline code (`<c>Type</c>`) (`PSGFR40`). - `MakeComponentSurfaceNonPublicCodeFixProvider` — makes the exposing member, or its declaring type, non-public (`PSGFR41`). @@ -127,7 +147,7 @@ analyzer rules above, including: `TypeLibraryMarkerDefaultInitializerCodeFixProvider`, `MakeTypeLibrarySpecPartialCodeFixProvider`, `RenameTypeLibrarySpecCodeFixProvider`, and `TypeLibraryMemberTypeCodeFixProvider`. -See [Guide.md](Guide.md#19-extension-class-conventions) for the extension-class conventions the +See [Guide.md](../Guide.md#19-extension-class-conventions) for the extension-class conventions the `PSGFR34`–`PSGFR38` rules enforce. ## Roslyn component discovery diff --git a/docs/wiki/Attribute-Data-Models.md b/docs/wiki/authoring/Attribute-Data-Models.md similarity index 98% rename from docs/wiki/Attribute-Data-Models.md rename to docs/wiki/authoring/Attribute-Data-Models.md index 210d4c8..8941a2b 100644 --- a/docs/wiki/Attribute-Data-Models.md +++ b/docs/wiki/authoring/Attribute-Data-Models.md @@ -251,8 +251,8 @@ await Assert.That(model.Log.Level).IsEqualTo(LogLevel.Information); For each model, cover positional, by-name and named-argument applications, an application that omits every optional argument, and — for a nested model — an outer attribute missing a mapped member. See -[Testing.md](Testing.md#testing-attribute-applications) for the factory and -[Testing-TUnit.md](Testing-TUnit.md#assertion-extensions) for the assertions. +[Testing.md](../Testing.md#testing-attribute-applications) for the factory and +[Testing-TUnit.md](../testing/Testing-TUnit.md#assertion-extensions) for the assertions. ## Generic type arguments diff --git a/docs/wiki/Code-Writer.md b/docs/wiki/authoring/Code-Writer.md similarity index 91% rename from docs/wiki/Code-Writer.md rename to docs/wiki/authoring/Code-Writer.md index 02b21d1..d9aee43 100644 --- a/docs/wiki/Code-Writer.md +++ b/docs/wiki/authoring/Code-Writer.md @@ -28,7 +28,7 @@ when it was opened: - Pass `throwOnUnclosedScopes: false` explicitly when a test intentionally materializes partial output. Scope tracking has a real cost — every scope open captures a `StackTrace` and allocates a per-scope -record — which is why production leaves it off (see [Performance.md](Performance.md)). +record — which is why production leaves it off (see [Performance.md](../operations/Performance.md)). ## Primitives @@ -76,9 +76,9 @@ writer.RecordClass( // writer.Class("C"); // public sealed partial class C; ``` -The semicolon-terminated form is valid for `Class`, `Struct`, `RecordClass`, `RecordStruct`, and `Interface` -(primary-constructor parameters, base types, and `where` constraints are still written). Enums and delegates -always require a body / self-terminate. +The semicolon-terminated form is valid for `Class`, `Struct`, `RecordClass`, `RecordStruct`, `Union`, and +`Interface` (primary-constructor parameters, base types, case types, and `where` constraints are still +written). Enums and delegates always require a body / self-terminate. ```csharp writer.Class( @@ -108,9 +108,9 @@ writer.Class( ); ``` -The same pattern applies to `Struct`, `RecordClass`, `RecordStruct`, `Interface`, `Enum` (+ `EnumField`), -`Type` (kind-driven), `Delegate`, `AttributeClass`, `Method`/`PartialMethod`/`MethodExpression`, -`Property`/`PropertyExpression`, `Indexer`, `Field`, and `Operator`. +The same pattern applies to `Struct`, `RecordClass`, `RecordStruct`, `Union`, `Interface`, `Enum` +(+ `EnumField`), `Type` (kind-driven), `Delegate`, `AttributeClass`, `Method`/`PartialMethod`/ +`MethodExpression`, `Property`/`PropertyExpression`, `Indexer`, `Field`, and `Operator`. ### Scope forms @@ -156,6 +156,49 @@ The receiver must be a plain named type; composed references (arrays, pointers, type parameters and `dynamic`) and the null literal are rejected. Use the callback form `writer.ExtensionBlock(receiver, body => ...)` for a complete block in one call. +### C# 15 unions + +A union declaration lists its case types in parentheses after the type name. Set +`UnionCaseTypes`; the primary-constructor list is reserved for `Class`/`Struct`/`RecordClass`/ +`RecordStruct`, so a union rejects both it and a base type. Case types, generic parameters, interfaces, +and `where` constraints are supported, and the declaration can be semicolon-terminated or have a body: + +```csharp +TypeReference cat = new TypeIdentity("Cat", "Example"), dog = new TypeIdentity("Dog", "Example"); + +// public union Pet(Cat, Dog); +writer.Union("Pet", [cat, dog], TypeDeclarationAccessibility.Public); + +// public union OneOrMore<T>(T, IEnumerable<T>) { } +using (writer.UnionScope("OneOrMore", [t, enumerableOfT], TypeDeclarationAccessibility.Public)) +{ +} +``` + +A union is lowered by the compiler to a `struct` with a single `object?` value, so value-type case types +are boxed. Use a hand-written `[Union]` struct implementing `IUnion` with `HasValue` and +`TryGetValue` overloads when that boxing is measurable. + +### C# 15 closed hierarchies + +Set `IsClosed` on a class or record class to emit the C# 15 `closed` modifier. A closed type is implicitly +abstract and cannot also be `static` or `abstract`; `IsSealed` is ignored because the two modifiers are +mutually exclusive: + +```csharp +// public closed partial record class JobStatus; +writer.RecordClass( + new TypeDeclarationOptions("JobStatus", TypeDeclarationAccessibility.Public) + { + IsClosed = true, + } +); +``` + +A direct subtype must be declared in the same assembly. A non-closed descendant of a closed type can +still be derived from elsewhere; mark intermediate types `closed` too when the hierarchy should stay +exhaustive. + ### Enums Generated enums are emitted with the `[Embedded]` marker attribute by default — the same default as @@ -637,11 +680,11 @@ The framework's public compiler-visible properties are `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes`, `PurviewSourceGeneratorFrameworkEnableLogging`, `PurviewSourceGeneratorFrameworkLoggingSessionId`, and -`PurviewSourceGeneratorFrameworkLanguageVersion`. See [Packaging.md](Packaging.md) for the full +`PurviewSourceGeneratorFrameworkLanguageVersion`. See [Packaging.md](../operations/Packaging.md) for the full self-contained generator packaging guidance. ## Samples -The [`SourceGeneratorFramework.ExampleGenerator`](../../src/src/SourceGeneratorFramework.ExampleGenerator) +The [`SourceGeneratorFramework.ExampleGenerator`](../../../src/src/SourceGeneratorFramework.ExampleGenerator) reference implementation demonstrates these APIs end-to-end, including the `CodeWriterSampleGenerator`, which compiles a best-practice sample class for every `[GenerateCodeWriterSample]` target. diff --git a/docs/wiki/Component-Failures.md b/docs/wiki/authoring/Component-Failures.md similarity index 100% rename from docs/wiki/Component-Failures.md rename to docs/wiki/authoring/Component-Failures.md diff --git a/docs/wiki/Incremental-Pipeline.md b/docs/wiki/authoring/Incremental-Pipeline.md similarity index 97% rename from docs/wiki/Incremental-Pipeline.md rename to docs/wiki/authoring/Incremental-Pipeline.md index 76e15f2..6003ae0 100644 --- a/docs/wiki/Incremental-Pipeline.md +++ b/docs/wiki/authoring/Incremental-Pipeline.md @@ -3,7 +3,7 @@ `IncrementalPipeline` provides extension methods for composing `IncrementalValueProvider<T>` and `IncrementalValuesProvider<T>` pipelines — attribute-based discovery, generation-context creation, disable-property checks, and thin source-output registration. It is designed around the golden rule -from the [best-practices guide](Guide.md): **pipeline values must be immutable and value-equatable.** +from the [best-practices guide](../Guide.md): **pipeline values must be immutable and value-equatable.** ## GenerationContext @@ -264,5 +264,5 @@ are removed when the test run completes. ## Tracking names and step-cache tests The framework's pipeline helpers assign a tracking name to every stage so cache tests can assert -which stages were recomputed. See [Step-Cache-Tests.md](Step-Cache-Tests.md) for the tracking-name +which stages were recomputed. See [Step-Cache-Tests.md](../testing/Step-Cache-Tests.md) for the tracking-name table and the golden test matrix. \ No newline at end of file diff --git a/docs/wiki/Type-Library.md b/docs/wiki/authoring/Type-Library.md similarity index 99% rename from docs/wiki/Type-Library.md rename to docs/wiki/authoring/Type-Library.md index 0c5b6a9..2286bfe 100644 --- a/docs/wiki/Type-Library.md +++ b/docs/wiki/authoring/Type-Library.md @@ -17,7 +17,7 @@ partial can merge with it (TLB0015 enforces the matching `public static partial` in-repo consumers such as code fixers and sibling assemblies can compile against it. When the component is packaged, the merge tool internalizes every framework-owned type in the shipped analyzer (including the `TypeIdentity`/`TypeReference`/`PurviewTypeLibrary` members this library exposes), so -nothing leaks out of the package; see [Packaging.md](Packaging.md). +nothing leaks out of the package; see [Packaging.md](../operations/Packaging.md). Author documentation is copied into the generated library. A `cref` that targets a framework type is rendered as inline code (`<c>TypeReference</c>`) while it is copied, because the generated file's @@ -364,4 +364,4 @@ The generator carries these same diagnostics on its `GeneratorResult` and gates `ShouldProcess`. Most are blocking (`IsBlocking: true`) and stop generation, but the non-blocking rules — `TLB0010` (marker without `= default`) and `TLB0013` (warning) — allow generation to continue, so a spec with those issues still produces the type library. See -[`GeneratorResult` diagnostics that don't stop generation](../../src/src/SourceGeneratorFramework/Sdk/README.md#diagnostics-that-dont-stop-generation). \ No newline at end of file +[`GeneratorResult` diagnostics that don't stop generation](../../../src/src/SourceGeneratorFramework/Sdk/README.md#diagnostics-that-dont-stop-generation). \ No newline at end of file diff --git a/docs/wiki/diagrams/incremental-pipeline-preferred.c4 b/docs/wiki/diagrams/incremental-pipeline-preferred.c4 new file mode 100644 index 0000000..83cec06 --- /dev/null +++ b/docs/wiki/diagrams/incremental-pipeline-preferred.c4 @@ -0,0 +1,31 @@ +specification { + element input + element transform + element model + element output +} + +model { + input = input 'Roslyn input' + discovery = transform 'Cheap discovery' + extraction = transform 'Semantic extraction' + equatable = model 'Small equatable model' + validation = transform 'Validation / transformation' + generation = model 'Generation model' + source = output 'Source output' + + input -> discovery + discovery -> extraction + extraction -> equatable + equatable -> validation + validation -> generation + generation -> source +} + +views { + view preferredPipeline { + title 'Preferred incremental pipeline' + include * + autoLayout LeftRight + } +} diff --git a/docs/wiki/Packaging.md b/docs/wiki/operations/Packaging.md similarity index 98% rename from docs/wiki/Packaging.md rename to docs/wiki/operations/Packaging.md index d59ab27..450c6e4 100644 --- a/docs/wiki/Packaging.md +++ b/docs/wiki/operations/Packaging.md @@ -267,11 +267,11 @@ accessibility in the component's **bin output**: TLB0015 requires a hand-written fixers and sibling assemblies compile against it. Only the merged analyzer artifact internalizes them (see above), so self-containment is enforced at the merge boundary without rewriting the generated accessibility an in-repo consumer compiles against. A merged artifact is never referenced as a -compile-time dependency, which is what makes that split safe. See [Type-Library.md](Type-Library.md). +compile-time dependency, which is what makes that split safe. See [Type-Library.md](../authoring/Type-Library.md). > A merged component is an analyzer artifact and must never be referenced as a compile-time > dependency. Tests that need to run a *packaged* generator load it out of band — see -> [Testing-TUnit.md](Testing-TUnit.md). +> [Testing-TUnit.md](../testing/Testing-TUnit.md). The bundled `SelfContainedGeneratorAnalyzer` (PSGFR39) runs on every project that references the @@ -350,7 +350,7 @@ outer package must make the framework's compiler-visible properties visible to t assets from `Purview.SourceGeneratorFramework` are not automatically copied into the outer package. Include a `.props` file imported by the outer package that declares the property and its `CompilerVisibleProperty` entry (see -[Code-Writer.md](Code-Writer.md#generators-embedded-in-another-package)), and pack it using the outer +[Code-Writer.md](../authoring/Code-Writer.md#generators-embedded-in-another-package)), and pack it using the outer package's ID so NuGet imports it automatically: ```xml @@ -387,7 +387,7 @@ generation is: > `netstandard2.0`, `net10.0`, and `net11.0`; Roslyn 5.9 ships `net10.0`/`netstandard2.0` package > assets, so both test targets load the test runner. -See [Guide.md](Guide.md) sections 14–18 for the full discussion of Roslyn versioning, multi-version +See [Guide.md](../Guide.md) sections 14–18 for the full discussion of Roslyn versioning, multi-version packaging strategies, and the recommended generator project configuration. ## Recommended generator project configuration diff --git a/docs/wiki/Performance.md b/docs/wiki/operations/Performance.md similarity index 97% rename from docs/wiki/Performance.md rename to docs/wiki/operations/Performance.md index 2bbf4d0..e4fc9ed 100644 --- a/docs/wiki/Performance.md +++ b/docs/wiki/operations/Performance.md @@ -1,7 +1,7 @@ # Performance Benchmark results are produced by the benchmarks project -([`SourceGeneratorFramework.Benchmarks`](../../src/src/SourceGeneratorFramework.Benchmarks)) using +([`SourceGeneratorFramework.Benchmarks`](../../../src/src/SourceGeneratorFramework.Benchmarks)) using [BenchmarkDotNet](https://benchmarkdotnet.org) and folded here for reference. ## What is measured @@ -11,7 +11,7 @@ All benchmarks measure the **production** code path: generator runs configure Scope tracking is a testing/debug feature and is excluded here because capturing an opening `StackTrace` per scope dominates both time and allocation (for 1000 small classes it inflates the writer benchmark from ~1.6 ms/2.3 MB to ~19 ms/22 MB). Tests opt into it so an unclosed `using` or -block fails fast; see [Code-Writer.md](Code-Writer.md#construction-and-scope-validation). +block fails fast; see [Code-Writer.md](../authoring/Code-Writer.md#construction-and-scope-validation). ## Environment diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/operations/Release-Flow.md similarity index 85% rename from docs/wiki/Release-Flow.md rename to docs/wiki/operations/Release-Flow.md index 19f4f88..971c3b1 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/operations/Release-Flow.md @@ -14,9 +14,9 @@ The current version lives in the repository-root `package.json`: } ``` -The version is read by the build tooling (for example `just version` runs -`bun -p "require('./package.json').version"`), and GitHub releases are tagged `v<version>`, e.g. -`v1.0.0-prerelease.42`. +The version is read by the build tooling: `just version` reads this field directly, and +`just bump major|minor|patch` increments one part while resetting the parts after it. GitHub +releases are tagged `v<version>`, e.g. `v1.0.0-prerelease.42`. ## Workflows @@ -39,11 +39,12 @@ The `Justfile` wraps the shared `Purview.Build` pipeline (a pinned .NET local to | Recipe | Pipeline mode | Purpose | | --- | --- | --- | -| `just pipeline-pr` | default | Restore, build, lint, tests. | +| `just pipeline-pr` | default | Restore, build, lint, test, pack, and validate. | | `just pipeline-build` | `--Build:RunTests=false --Release:Mode=None` | Build-only pipeline. | | `just pipeline-tests` | `--Build:RunTests=true --Release:Mode=None` | Build with tests. | +| `just pipeline-pack-validate` | `--Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None` | Pack and validate without publishing. | | `just pipeline-release` | `--Release:Mode=NuGet` | Full release: build, test, pack, publish. | -| `just pipeline-local-release` | `--Release:Mode=LocalNuGet` | Build, test, pack, and publish to a local NuGet feed. | +| `just pipeline-local-release` | `--Release:Mode=LocalNuGet` | Format, then build, test, pack, and publish to a local NuGet feed. | Convenience recipes also exist for building (`just build`), testing (`just test`, `just test-unit`), packing (`just pack`), benchmarking (`just benchmark`), linting (`just lint-check`/`just lint-fix`), diff --git a/docs/wiki/DotNet-11.md b/docs/wiki/platform/DotNet-11.md similarity index 96% rename from docs/wiki/DotNet-11.md rename to docs/wiki/platform/DotNet-11.md index ce62696..f8a5e80 100644 --- a/docs/wiki/DotNet-11.md +++ b/docs/wiki/platform/DotNet-11.md @@ -81,7 +81,11 @@ Opting out is per project: `<PurviewRuntimeAsync>false</PurviewRuntimeAsync>` (o ## Union types and closed hierarchies -Both C# 15 features were evaluated against the API surface rather than adopted wholesale. +Both C# 15 features were evaluated against the API surface rather than adopted wholesale. The +framework does not *use* either feature in its own shared sources, but `CodeWriter` can *emit* both: +`TypeDeclarationKind.Union` with `UnionCaseTypes` produces a `union` declaration, and +`TypeDeclarationOptions.IsClosed` produces the `closed` modifier. That lets a generator targeting C# +15 write these constructs through the structured API instead of raw text. ### Union types diff --git a/docs/wiki/Step-Cache-Tests.md b/docs/wiki/testing/Step-Cache-Tests.md similarity index 100% rename from docs/wiki/Step-Cache-Tests.md rename to docs/wiki/testing/Step-Cache-Tests.md diff --git a/docs/wiki/Testing-TUnit.md b/docs/wiki/testing/Testing-TUnit.md similarity index 99% rename from docs/wiki/Testing-TUnit.md rename to docs/wiki/testing/Testing-TUnit.md index 124e427..e4e1502 100644 --- a/docs/wiki/Testing-TUnit.md +++ b/docs/wiki/testing/Testing-TUnit.md @@ -227,7 +227,7 @@ the generated (or fixed) code for you. To assert a nullable expected type, use the test-only `query.MakeNullable(...)` extension: it resolves the annotation against the query's compilation and, unlike `TypeReference.Nullable()` / -`TypeIdentity.MakeNullable()`, does not trigger the `PSGFR16` context-overload suggestion (tests have +`TypeIdentity.MakeNullable()`, does not trigger the `PSGFR16` context-overload warning (tests have no generation context to pass). ```csharp diff --git a/global.json b/global.json index 86e68b6..2e691ee 100644 --- a/global.json +++ b/global.json @@ -5,7 +5,7 @@ "allowPrerelease": false }, "msbuild-sdks": { - "Purview.BuildSdk": "2.0.0" + "Purview.BuildSdk": "2.1.0" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/package.json b/package.json index 95154d9..404a329 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sourcegenerator-framework", - "version": "2.0.0", + "version": "2.1.0", "license": "MIT", "author": { "name": "Kieron Lanning", diff --git a/skills-lock.json b/skills-lock.json index 8c8eb26..b12cdbc 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,89 +1,233 @@ { - "version": 1, + "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" + }, + "add-new-element-shape": { + "source": "likec4/likec4", + "sourceType": "github", + "skillPath": ".agents/skills/add-new-element-shape/SKILL.md", + "computedHash": "2b7a0448be1ca542369bb17b422ba0ea64d42c9d09bb65f9960cd67483c143e7" + }, + "audit-catalogue-projects": { + "source": "purview-dev/purview-dev.github.io", + "sourceType": "github", + "skillPath": ".agents/skills/audit-catalogue-projects/SKILL.md", + "computedHash": "81cba9a18c65663eb0ef8657086866b53ffbe77d82f9d3d1623fa1bd9fddcdb1" + }, + "changeset-generator": { + "source": "likec4/likec4", + "sourceType": "github", + "skillPath": ".agents/skills/changeset-generator/SKILL.md", + "computedHash": "0facbaace87a0422bc994d8ab22397e7981f2a70c0006c6e76e147bed6e925e5" + }, + "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" + }, + "likec4-cli-codegen-regression": { + "source": "likec4/likec4", + "sourceType": "github", + "skillPath": ".agents/skills/likec4-cli-codegen-regression/SKILL.md", + "computedHash": "bfcda79147f7171fb3b7e903f6332a204fae7d0a8437f1a9430c411c38403e6f" + }, + "likec4-dsl": { + "source": "likec4/likec4", + "sourceType": "github", + "skillPath": "skills/likec4-dsl/SKILL.md", + "computedHash": "0e63f8b56bb5496b289eed41e213ace380d03cfe0d0b234e1846f0fc1b1bab39" + }, + "likec4-gh-pr-triage": { + "source": "likec4/likec4", + "sourceType": "github", + "skillPath": ".agents/skills/likec4-gh-pr-triage/SKILL.md", + "computedHash": "c447c622e06e8617ad64d8f120992796e6c779965505c42882bd91214ed2451a" + }, + "likec4-issue-repro": { + "source": "likec4/likec4", + "sourceType": "github", + "skillPath": ".agents/skills/likec4-issue-repro/SKILL.md", + "computedHash": "d97f101721334af644c98a90f5a9baf20f9908ffa2bc3f28dc9f931f263e84f7" + }, + "likec4-project-config-workflow": { + "source": "likec4/likec4", + "sourceType": "github", + "skillPath": ".agents/skills/likec4-project-config-workflow/SKILL.md", + "computedHash": "25d9de035348170aac1cb3bf5b6105568e742b55b6cf67173d811a2152978573" + }, + "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", "skillPath": "skills/purview-architecture/SKILL.md", - "computedHash": "d194bd0f30091ffab6ef35c29582e56511d2daca32fdbb1942c50134198f9270" + "computedHash": "2500e935ff2ccb350f26ebb9be321dc9f99800121aa9412fdc597f95c2c465c8" }, "purview-architecture-testing": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-architecture-testing/SKILL.md", - "computedHash": "5907c6bf28788b755b752041e2f0cd88222604a949988d487c25940b6284fce4" + "computedHash": "23af6d9baeb408e1e3f4034cef6539d7efa3fecb1b4da918cb539bd8dfbd5a00" }, "purview-buildsdk-adoption": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-adoption/SKILL.md", - "computedHash": "ca2e13fc8074281f8ca2b510b742ae99db28f97d78f5fb36a6f27936a91941fc" + "computedHash": "ed38157d532c45582bc3f768ca6605c4cbc1339fbbb6a45e34a8badee06af06f" }, "purview-buildsdk-configuration": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-configuration/SKILL.md", - "computedHash": "88b4a655efdc058b9a10adb1b5cbcd44e89bff6ed8c858e066092e5be0ca3e0b" + "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": "7c30062fe592105b7a2539384843759521c5a3f90ace8f7c60f0e6ae2405b0b1" + "computedHash": "8b39fdeba80cad1cfd19d394209affc95d2dbc3e6a2e0358d14dc54ba6892ed7" }, "purview-buildsdk-project-layout": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-project-layout/SKILL.md", - "computedHash": "101f09c92cf97e93387b926c48adab9598a0cf0a64a4932d41085ab0a0078cfa" + "computedHash": "e2df530972d850894ea3129f30041b5f0a16a067b6a6b037188f920e81d488bb" }, "purview-buildsdk-repository-review": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-repository-review/SKILL.md", - "computedHash": "d366821995ff410caa436ccb996eaa6429f1ba51ba985b210d74ec3963f6c06f" + "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": "b61cce9f0f213469c3acab4dd5ebaec450ee2be730680b166c9718c901aedffe" + "computedHash": "5a8cffd36dc696ec86276ddd089e9f859d4571b7606b46c0b82a5c52d5935e16" }, "purview-buildsdk-skill-tests": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-skill-tests/SKILL.md", - "computedHash": "807b64466de0a871e5f8e678e7686c48a06d8bb9d42a03448f604a35d40d3935" + "computedHash": "bfa5798b756ab0fdc174fcba98184a516135f61f5b99453fc62b042a5c3551fa" }, "purview-buildsdk-testing": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-testing/SKILL.md", - "computedHash": "d6a2ac1167faf68e8c27493de81c2e5b0d8183e523de4411aca5f5b9249c8a9c" + "computedHash": "5faaf12203c835f1af452137304feed6b99c5e94d7fb1e628ba0b304751914f9" + }, + "purview-component-compatibility": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-component-compatibility/SKILL.md", + "computedHash": "070634caa90d032e2319d6799edbee973be0f9380008917474909328e74f143b" }, "purview-engineering-principles": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-engineering-principles/SKILL.md", - "computedHash": "fd38d374b816ecf77964c41486e7c734ea34998c3f2a5538c90a2ba793905994" + "computedHash": "d1b0d2b50e635160f96d03d2790cb725242434bb28b1b47913371bb39d83215b" + }, + "purview-org-harvest": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-org-harvest/SKILL.md", + "computedHash": "040b34d9cd41f23e71dbe1500b0748793377703d1032e1135ed0a3eeeee4a7f8" }, "purview-persistence": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-persistence/SKILL.md", - "computedHash": "0523ecc210bf3571e2fccb859128a499fd80694e9d4089b377a8d39d73c028f4" + "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-telemetry": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-telemetry/SKILL.md", - "computedHash": "08ba26e1379dfa2bb826ddfd011e95ac84cd583b865f0b89673d7302b2c2c27d" + "computedHash": "f92aab10e3d2be7b24be559316537ba7d0485d0e479c67f4d5c6126f5f2fc652" }, "purview-testing-conventions": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-testing-conventions/SKILL.md", - "computedHash": "388b0a79b565ed45a8513d77e8ed6e3d4491f96b43328cb09933cec7811c10f6" + "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" + }, + "vscode-extension-screenshot-evidence": { + "source": "likec4/likec4", + "sourceType": "github", + "skillPath": ".agents/skills/vscode-extension-screenshot-evidence/SKILL.md", + "computedHash": "b35ba1ab287178ca38634e6a330012179a9c52685e813248e679e6a45d9ecd4f" } } } diff --git a/skills/purview-sourcegenerator-codewriter/SKILL.md b/skills/purview-sourcegenerator-codewriter/SKILL.md index 68e79d3..b256caa 100644 --- a/skills/purview-sourcegenerator-codewriter/SKILL.md +++ b/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/skills/purview-sourcegenerator-diagnostics/SKILL.md b/skills/purview-sourcegenerator-diagnostics/SKILL.md index b80ebe7..d5b43b4 100644 --- a/skills/purview-sourcegenerator-diagnostics/SKILL.md +++ b/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/skills/purview-sourcegenerator-testing/SKILL.md b/skills/purview-sourcegenerator-testing/SKILL.md index 5590a89..af56ece 100644 --- a/skills/purview-sourcegenerator-testing/SKILL.md +++ b/skills/purview-sourcegenerator-testing/SKILL.md @@ -89,7 +89,7 @@ result.Generated().GetMethod("Format").HasParameters(TypeReference.Create<string When a test expects a nullable annotation, prefer the test-only `query.MakeNullable(type)` extension (on a `CodeQuery`, accepting a `TypeReference` or `TypeIdentity`). It resolves the annotation against the query's compilation and, unlike `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not trigger the -`PSGFR16` context-overload suggestion (tests have no generation context to pass). +`PSGFR16` context-overload warning (tests have no generation context to pass). Member chaining from a type declaration (`MemberQueryExtensions`): diff --git a/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Shipped.md b/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Shipped.md index 96cc859..989b257 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Shipped.md +++ b/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Shipped.md @@ -12,14 +12,14 @@ PSGFR11 | Purview.SourceGeneratorFramework | Warning | Prefer ForAttributeWithMe PSGFR12 | Purview.SourceGeneratorFramework | Warning | Use IIncrementalGenerator instead of ISourceGenerator PSGFR14 | Purview.SourceGeneratorFramework | Warning | Avoid RegisterImplementationSourceOutput PSGFR15 | Purview.SourceGeneratorFramework | Warning | Pipeline model collection lacks sequence equality -PSGFR16 | Purview.SourceGeneratorFramework | Info | Prefer the nullable-context overload +PSGFR16 | Purview.SourceGeneratorFramework | Warning | Prefer the nullable-context overload PSGFR17 | Purview.SourceGeneratorFramework | Warning | Consume CodeWriter scopes with a using statement -PSGFR18 | Purview.SourceGeneratorFramework | Info | Prefer a structured CodeWriter declaration API -PSGFR19 | Purview.SourceGeneratorFramework | Info | Prefer a structured CodeWriter statement API -PSGFR20 | Purview.SourceGeneratorFramework | Info | Prefer the minimal CodeWriter overload -PSGFR21 | Purview.SourceGeneratorFramework | Info | Prefer HashDefines for conditional compilation -PSGFR22 | Purview.SourceGeneratorFramework | Info | Prefer PragmaDisable for warning suppression -PSGFR23 | Purview.SourceGeneratorFramework | Info | Prefer the structured CodeWriter conditional API +PSGFR18 | Purview.SourceGeneratorFramework | Warning | Prefer a structured CodeWriter declaration API +PSGFR19 | Purview.SourceGeneratorFramework | Warning | Prefer a structured CodeWriter statement API +PSGFR20 | Purview.SourceGeneratorFramework | Warning | Prefer the minimal CodeWriter overload +PSGFR21 | Purview.SourceGeneratorFramework | Warning | Prefer HashDefines for conditional compilation +PSGFR22 | Purview.SourceGeneratorFramework | Warning | Prefer PragmaDisable for warning suppression +PSGFR23 | Purview.SourceGeneratorFramework | Warning | Prefer the structured CodeWriter conditional API PSGFR24 | Purview.SourceGeneratorFramework | Warning | CodeFixProvider is not marked with ExportCodeFixProvider PSGFR25 | Purview.SourceGeneratorFramework | Warning | DiagnosticAnalyzer is not marked with DiagnosticAnalyzer PSGFR26 | Purview.SourceGeneratorFramework | Error | Source generator is not marked with Generator diff --git a/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md b/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md index 14af6b1..32b1012 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md @@ -14,7 +14,7 @@ PSGFR30 | Purview.SourceGeneratorFramework | Warning | Prefer static lambdas in PSGFR31 | Purview.SourceGeneratorFramework | Warning | Prefer TargetSymbol over GetDeclaredSymbol(TargetNode) PSGFR32 | Purview.SourceGeneratorFramework | Warning | Avoid NormalizeWhitespace when generating source PSGFR33 | Purview.SourceGeneratorFramework | Warning | Pipeline model retains a Roslyn object -PSGFR34 | Purview.SourceGeneratorFramework | Info | Prefer extension blocks over classic extension methods +PSGFR34 | Purview.SourceGeneratorFramework | Warning | Prefer extension blocks over classic extension methods PSGFR35 | Purview.SourceGeneratorFramework | Warning | Extension class name does not match the extended type PSGFR36 | Purview.SourceGeneratorFramework | Warning | Extension class is not placed in the extended type's namespace/folder PSGFR37 | Purview.SourceGeneratorFramework | Warning | Extension class extends multiple receiver types diff --git a/src/src/SourceGeneratorFramework.Analyzers/AttributeDataModelNamingAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/AttributeDataModelNamingAnalyzer.cs index a80db01..6084259 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/AttributeDataModelNamingAnalyzer.cs +++ b/src/src/SourceGeneratorFramework.Analyzers/AttributeDataModelNamingAnalyzer.cs @@ -121,11 +121,11 @@ INamedTypeSymbol targetAttribute if (source.Kind == MappedSourceKind.ConstructorIndex) return; - var match = Match(source, targetAttribute); - if (match.Kind == MatchKind.Exact) + var (kind, actualName) = Match(source, targetAttribute); + if (kind == MatchKind.Exact) return; - if (match.Kind == MatchKind.CaseOnly) + if (kind == MatchKind.CaseOnly) { context.ReportDiagnostic( Diagnostic.Create( @@ -133,7 +133,7 @@ INamedTypeSymbol targetAttribute location, source.Name, KindText(source), - match.ActualName, + actualName, targetAttribute.Name ) ); @@ -215,12 +215,12 @@ ImmutableHashSet<INamedTypeSymbol> visited continue; } - var match = Match(source, outerAttribute); - if (match.Kind == MatchKind.Exact) + var (kind, actualName) = Match(source, outerAttribute); + if (kind == MatchKind.Exact) { resolved = true; } - else if (match.Kind == MatchKind.CaseOnly) + else if (kind == MatchKind.CaseOnly) { context.ReportDiagnostic( Diagnostic.Create( @@ -228,7 +228,7 @@ ImmutableHashSet<INamedTypeSymbol> visited location, source.Name, KindText(source), - match.ActualName, + actualName, outerAttribute.Name ) ); @@ -395,6 +395,8 @@ static IEnumerable<MappedProperty> ReadProperties(INamedTypeSymbol model, Marker if (generateAttribute.ConstructorArguments.Length == 0) return null; + // The generator accepts either a type or a string, but the string is resolved to a type at compile time. The + // analyzer only needs the type to read its members, so it does not report a missing type. return generateAttribute.ConstructorArguments[0].Value switch { INamedTypeSymbol type => type.IsUnboundGenericType ? type.OriginalDefinition : type, @@ -491,6 +493,8 @@ readonly record struct MarkerTypes( if (generate is null) return null; + // The other marker attributes are optional: the generator accepts the default behavior when they are missing, and + // the analyzer does not report a diagnostic when they are missing. return new( generate, compilation.GetTypeByMetadataName(Namespace + "PropertyAttribute"), diff --git a/src/src/SourceGeneratorFramework.Analyzers/OrphanedFixableDiagnosticIdAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/OrphanedFixableDiagnosticIdAnalyzer.cs index 900d820..326e05c 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/OrphanedFixableDiagnosticIdAnalyzer.cs +++ b/src/src/SourceGeneratorFramework.Analyzers/OrphanedFixableDiagnosticIdAnalyzer.cs @@ -46,7 +46,7 @@ public override void Initialize(AnalysisContext context) static void AnalyzeCompilation(CompilationAnalysisContext context) { - HashSet<string> descriptorIds = new(StringComparer.Ordinal); + HashSet<string> descriptorIds = [with(StringComparer.Ordinal)]; List<(string TypeName, Location Location, string Id)> fixerTargets = []; var hasSourceAnalyzer = false; diff --git a/src/src/SourceGeneratorFramework.Analyzers/PipelineModelDiscovery.cs b/src/src/SourceGeneratorFramework.Analyzers/PipelineModelDiscovery.cs index 301c6f3..50b21ac 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/PipelineModelDiscovery.cs +++ b/src/src/SourceGeneratorFramework.Analyzers/PipelineModelDiscovery.cs @@ -39,7 +39,7 @@ public static HashSet<INamedTypeSymbol> CollectPipelineModelTypes( ImmutableArray<INamedTypeSymbol> collectionTypes ) { - HashSet<INamedTypeSymbol> directModels = new(SymbolEqualityComparer.Default); + HashSet<INamedTypeSymbol> directModels = [with(SymbolEqualityComparer.Default)]; foreach (var typeSymbol in GetAllTypes(compilation.GlobalNamespace)) { @@ -53,7 +53,7 @@ ImmutableArray<INamedTypeSymbol> collectionTypes } } - HashSet<INamedTypeSymbol> expanded = new(SymbolEqualityComparer.Default); + HashSet<INamedTypeSymbol> expanded = [with(SymbolEqualityComparer.Default)]; Queue<INamedTypeSymbol> worklist = new(directModels); while (worklist.Count > 0) diff --git a/src/src/SourceGeneratorFramework.Analyzers/PreferExtensionKeywordAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/PreferExtensionKeywordAnalyzer.cs index 02221cf..f81b730 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/PreferExtensionKeywordAnalyzer.cs +++ b/src/src/SourceGeneratorFramework.Analyzers/PreferExtensionKeywordAnalyzer.cs @@ -21,7 +21,7 @@ public sealed class PreferExtensionKeywordAnalyzer : DiagnosticAnalyzer "Prefer extension blocks over classic extension methods", "Method '{0}' is a classic extension method; prefer a C# 14 extension(Receiver) block", "Purview.SourceGeneratorFramework", - DiagnosticSeverity.Info, + DiagnosticSeverity.Warning, isEnabledByDefault: true, description: "Use C# 14 extension(Receiver) blocks instead of classic static methods with a 'this' receiver parameter for a more coherent extension shape." ); diff --git a/src/src/SourceGeneratorFramework.Analyzers/PreferHashDefinesAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/PreferHashDefinesAnalyzer.cs index f66cc11..cabbaef 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/PreferHashDefinesAnalyzer.cs +++ b/src/src/SourceGeneratorFramework.Analyzers/PreferHashDefinesAnalyzer.cs @@ -20,7 +20,7 @@ public sealed class PreferHashDefinesAnalyzer : DiagnosticAnalyzer "Prefer HashDefines for conditional compilation", "'{0}' with a preprocessor directive should use 'HashDefines' or 'HashElse'", "Purview.SourceGeneratorFramework", - DiagnosticSeverity.Info, + DiagnosticSeverity.Warning, isEnabledByDefault: true, description: "Emitting #if/#else/#endif directives through raw text bypasses the structured HashDefines/HashElse APIs that write directives at column zero." ); diff --git a/src/src/SourceGeneratorFramework.Analyzers/PreferMinimalCodeWriterOverloadAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/PreferMinimalCodeWriterOverloadAnalyzer.cs index 7ec5eab..06000b9 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/PreferMinimalCodeWriterOverloadAnalyzer.cs +++ b/src/src/SourceGeneratorFramework.Analyzers/PreferMinimalCodeWriterOverloadAnalyzer.cs @@ -21,7 +21,7 @@ public sealed class PreferMinimalCodeWriterOverloadAnalyzer : DiagnosticAnalyzer "Prefer the minimal CodeWriter overload", "'{0}' should use the minimal overload '{1}'", "Purview.SourceGeneratorFramework", - DiagnosticSeverity.Info, + DiagnosticSeverity.Warning, isEnabledByDefault: true, description: "Constructing a declaration options value from only its primary-constructor arguments can be replaced by the minimal overload that takes those arguments directly." ); diff --git a/src/src/SourceGeneratorFramework.Analyzers/PreferNullableContextOverloadAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/PreferNullableContextOverloadAnalyzer.cs index 449dd05..6ccd10e 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/PreferNullableContextOverloadAnalyzer.cs +++ b/src/src/SourceGeneratorFramework.Analyzers/PreferNullableContextOverloadAnalyzer.cs @@ -21,7 +21,7 @@ public sealed class PreferNullableContextOverloadAnalyzer : DiagnosticAnalyzer "Prefer the nullable-context overload", "Pass GenerationSettings or CodeWriter to {0}() so the nullable annotation is emitted only when the target compilation supports it", "Purview.SourceGeneratorFramework", - DiagnosticSeverity.Info, + DiagnosticSeverity.Warning, isEnabledByDefault: true, description: "Use Nullable(GenerationSettings)/Nullable(CodeWriter) or MakeNullable(GenerationSettings)/MakeNullable(CodeWriter) when a generation context is available." ); diff --git a/src/src/SourceGeneratorFramework.Analyzers/PreferPragmaDisableAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/PreferPragmaDisableAnalyzer.cs index 371f967..c049fbf 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/PreferPragmaDisableAnalyzer.cs +++ b/src/src/SourceGeneratorFramework.Analyzers/PreferPragmaDisableAnalyzer.cs @@ -20,7 +20,7 @@ public sealed class PreferPragmaDisableAnalyzer : DiagnosticAnalyzer "Prefer PragmaDisable for warning suppression", "'{0}' with a pragma warning directive should use 'PragmaDisable' or 'OpenPragmasScope'", "Purview.SourceGeneratorFramework", - DiagnosticSeverity.Info, + DiagnosticSeverity.Warning, isEnabledByDefault: true, description: "Emitting #pragma warning directives through raw text bypasses the structured PragmaDisable/OpenPragmasScope APIs." ); diff --git a/src/src/SourceGeneratorFramework.Analyzers/PreferStructuredCodeWriterApiAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/PreferStructuredCodeWriterApiAnalyzer.cs index fdea0f5..5c41db9 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/PreferStructuredCodeWriterApiAnalyzer.cs +++ b/src/src/SourceGeneratorFramework.Analyzers/PreferStructuredCodeWriterApiAnalyzer.cs @@ -21,7 +21,7 @@ public sealed class PreferStructuredCodeWriterAPIAnalyzer : DiagnosticAnalyzer "Prefer a structured CodeWriter declaration API", "'{0}' with a declaration should use a structured API such as Class, Method, Property, or Field", "Purview.SourceGeneratorFramework", - DiagnosticSeverity.Info, + DiagnosticSeverity.Warning, isEnabledByDefault: true, description: "Emitting declaration syntax through raw text bypasses the structured, deterministic declaration APIs on CodeWriter." ); diff --git a/src/src/SourceGeneratorFramework.Analyzers/PreferStructuredCodeWriterIfBlockAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/PreferStructuredCodeWriterIfBlockAnalyzer.cs index f0a44aa..6da9c68 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/PreferStructuredCodeWriterIfBlockAnalyzer.cs +++ b/src/src/SourceGeneratorFramework.Analyzers/PreferStructuredCodeWriterIfBlockAnalyzer.cs @@ -20,7 +20,7 @@ public sealed class PreferStructuredCodeWriterIfBlockAnalyzer : DiagnosticAnalyz "Prefer the structured CodeWriter conditional API", "'{0}' with a conditional block should use '{1}'", "Purview.SourceGeneratorFramework", - DiagnosticSeverity.Info, + DiagnosticSeverity.Warning, isEnabledByDefault: true, description: "Emitting an if, else if, or else block through a generic block method bypasses the structured IfBlock, ElseIf, and Else APIs on CodeWriter." ); diff --git a/src/src/SourceGeneratorFramework.Analyzers/PreferStructuredCodeWriterStatementAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/PreferStructuredCodeWriterStatementAnalyzer.cs index 86d8441..f311863 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/PreferStructuredCodeWriterStatementAnalyzer.cs +++ b/src/src/SourceGeneratorFramework.Analyzers/PreferStructuredCodeWriterStatementAnalyzer.cs @@ -21,7 +21,7 @@ public sealed class PreferStructuredCodeWriterStatementAnalyzer : DiagnosticAnal "Prefer a structured CodeWriter statement API", "'{0}' with a statement should use '{1}'", "Purview.SourceGeneratorFramework", - DiagnosticSeverity.Info, + DiagnosticSeverity.Warning, isEnabledByDefault: true, description: "Emitting statement syntax through raw text bypasses the structured, deterministic statement APIs on CodeWriter." ); diff --git a/src/src/SourceGeneratorFramework.Analyzers/TypeLibraryValidationAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/TypeLibraryValidationAnalyzer.cs index 9457af3..20f8e6a 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/TypeLibraryValidationAnalyzer.cs +++ b/src/src/SourceGeneratorFramework.Analyzers/TypeLibraryValidationAnalyzer.cs @@ -216,12 +216,12 @@ bool mergesFramework // public spec leaves a public signature over a type the merge internalizes. ReportSpecAccessibility(context, typeSymbol, typeLocation, mergesFramework); - Dictionary<string, HashSet<string>> memberNamesByPath = new(StringComparer.Ordinal); - Dictionary<string, HashSet<string>> enumMemberNamesByGroup = new(StringComparer.Ordinal); - Dictionary<string, HashSet<decimal>> enumValuesByGroup = new(StringComparer.Ordinal); + Dictionary<string, HashSet<string>> memberNamesByPath = [with(StringComparer.Ordinal)]; + Dictionary<string, HashSet<string>> enumMemberNamesByGroup = [with(StringComparer.Ordinal)]; + Dictionary<string, HashSet<decimal>> enumValuesByGroup = [with(StringComparer.Ordinal)]; // Plain [TypeRef] TypeIdentity markers declare the enum types that [EnumValue] members reference. - HashSet<string> typeRefMarkersByPath = new(StringComparer.Ordinal); + HashSet<string> typeRefMarkersByPath = [with(StringComparer.Ordinal)]; foreach (var field in typeSymbol.GetMembers().OfType<IFieldSymbol>()) { var typeRef = GetAttribute(field, typeRefAttributeType); @@ -413,7 +413,7 @@ Location typeLocation var pathKey = placementNamespace ?? string.Empty; if (!memberNamesByPath.TryGetValue(pathKey, out var names)) { - names = new(StringComparer.Ordinal); + names = [with(StringComparer.Ordinal)]; memberNamesByPath[pathKey] = names; } @@ -508,7 +508,7 @@ enumValueDefinitionType is not null if (!enumMemberNamesByGroup.TryGetValue(groupKey, out var memberNames)) { - memberNames = new(StringComparer.Ordinal); + memberNames = [with(StringComparer.Ordinal)]; enumMemberNamesByGroup[groupKey] = memberNames; } @@ -598,7 +598,7 @@ enumValueDefinitionType is not null var groupKey = enumNamespace + "\0" + enumName; if (!enumMemberNamesByGroup.TryGetValue(groupKey, out var memberNames)) { - memberNames = new(StringComparer.Ordinal); + memberNames = [with(StringComparer.Ordinal)]; enumMemberNamesByGroup[groupKey] = memberNames; } @@ -733,7 +733,7 @@ static List<GeneratedTypeLibraryInfo> CollectGeneratedTypeLibraries( return []; List<GeneratedTypeLibraryInfo> libraries = []; - HashSet<string> seen = new(StringComparer.Ordinal); + HashSet<string> seen = [with(StringComparer.Ordinal)]; foreach (var spec in GetNamedTypes(compilation.Assembly.GlobalNamespace)) { diff --git a/src/src/SourceGeneratorFramework.CodeFixers/PreferMinimalCodeWriterOverloadCodeFixProvider.cs b/src/src/SourceGeneratorFramework.CodeFixers/PreferMinimalCodeWriterOverloadCodeFixProvider.cs new file mode 100644 index 0000000..1702716 --- /dev/null +++ b/src/src/SourceGeneratorFramework.CodeFixers/PreferMinimalCodeWriterOverloadCodeFixProvider.cs @@ -0,0 +1,60 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +/// <summary> +/// Rewrites a structured <c>CodeWriter</c> declaration call whose only argument is a bare +/// <c>*DeclarationOptions</c> construction to the minimal overload that takes those primary-constructor +/// arguments directly (<c>PSGFR20</c>). +/// </summary> +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(PreferMinimalCodeWriterOverloadCodeFixProvider))] +public sealed class PreferMinimalCodeWriterOverloadCodeFixProvider : CodeFixProvider +{ + internal const string EquivalenceKey = "UseMinimalOverload"; + + public override ImmutableArray<string> FixableDiagnosticIds => ["PSGFR20"]; + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + return; + + foreach (var diagnostic in context.Diagnostics) + { + var node = root.FindNode(diagnostic.Location.SourceSpan); + if (node is not InvocationExpressionSyntax invocation) + continue; + + if (invocation.ArgumentList.Arguments.Count != 1) + continue; + + var arguments = invocation.ArgumentList.Arguments[0].Expression switch + { + ObjectCreationExpressionSyntax { Initializer: null } creation => creation.ArgumentList, + ImplicitObjectCreationExpressionSyntax { Initializer: null } creation => creation.ArgumentList, + _ => null, + }; + if (arguments is null || arguments.Arguments.Count == 0) + continue; + + var newArgumentList = arguments.WithTriviaFrom(invocation.ArgumentList); + var replacement = invocation.WithArgumentList(newArgumentList); + + context.RegisterCodeFix( + CodeAction.Create( + "Use the minimal overload", + _ => Task.FromResult(context.Document.WithSyntaxRoot(root.ReplaceNode(invocation, replacement))), + EquivalenceKey + ), + diagnostic + ); + } + } +} diff --git a/src/src/SourceGeneratorFramework.CodeFixers/PreferPragmaDisableCodeFixProvider.cs b/src/src/SourceGeneratorFramework.CodeFixers/PreferPragmaDisableCodeFixProvider.cs new file mode 100644 index 0000000..12644f5 --- /dev/null +++ b/src/src/SourceGeneratorFramework.CodeFixers/PreferPragmaDisableCodeFixProvider.cs @@ -0,0 +1,115 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Purview.SourceGeneratorFramework.Analyzers; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +/// <summary> +/// Rewrites a raw <c>#pragma warning disable</c> text emission to <c>CodeWriter.PragmaDisable</c> +/// (<c>PSGFR22</c>). A <c>#pragma warning restore</c> has no single-call equivalent because the +/// structured API owns the restore through <c>OpenPragmasScope</c>, so that half is left unchanged. +/// </summary> +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(PreferPragmaDisableCodeFixProvider))] +public sealed class PreferPragmaDisableCodeFixProvider : CodeFixProvider +{ + internal const string EquivalenceKey = "UsePragmaDisable"; + + const string DisableDirective = "#pragma warning disable"; + + public override ImmutableArray<string> FixableDiagnosticIds => ["PSGFR22"]; + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + var semanticModel = await context + .Document.GetSemanticModelAsync(context.CancellationToken) + .ConfigureAwait(false); + if (root is null || semanticModel is null) + return; + + foreach (var diagnostic in context.Diagnostics) + { + var node = root.FindNode(diagnostic.Location.SourceSpan); + if (node is not InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax member } invocation) + continue; + + if (invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression is not ExpressionSyntax expression) + continue; + + if ( + expression is InterpolatedStringExpressionSyntax + && !semanticModel.GetConstantValue(expression, context.CancellationToken).HasValue + ) + continue; + + if ( + !CodeWriterLiteralClassifier.TryGetLiteralText( + expression, + semanticModel, + context.CancellationToken, + out var text + ) + || text is null + || !TryGetCodes(text, out var codes) + ) + continue; + + var newMemberAccess = SyntaxFactory + .MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + member.Expression, + SyntaxFactory.IdentifierName("PragmaDisable") + ) + .WithTriviaFrom(member); + + var newArguments = SyntaxFactory.ArgumentList( + SyntaxFactory.SeparatedList( + codes.Select(code => + SyntaxFactory.Argument( + SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(code) + ) + ) + ) + ) + ); + + var replacement = SyntaxFactory + .InvocationExpression(newMemberAccess, newArguments) + .WithTriviaFrom(invocation); + + context.RegisterCodeFix( + CodeAction.Create( + "Use PragmaDisable()", + _ => Task.FromResult(context.Document.WithSyntaxRoot(root.ReplaceNode(invocation, replacement))), + EquivalenceKey + ), + diagnostic + ); + } + } + + static bool TryGetCodes(string text, out string[] codes) + { + codes = []; + + var trimmed = text.Trim(); + if (!trimmed.StartsWith(DisableDirective, StringComparison.Ordinal)) + return false; + + var rest = trimmed.Substring(DisableDirective.Length).Trim(); + var commentIndex = rest.IndexOf("//", StringComparison.Ordinal); + if (commentIndex >= 0) + rest = rest.Substring(0, commentIndex).Trim(); + + codes = rest.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + return codes.Length != 0; + } +} diff --git a/src/src/SourceGeneratorFramework.CodeFixers/PreferStructuredCodeWriterIfBlockCodeFixProvider.cs b/src/src/SourceGeneratorFramework.CodeFixers/PreferStructuredCodeWriterIfBlockCodeFixProvider.cs index 4a51cf4..d11c007 100644 --- a/src/src/SourceGeneratorFramework.CodeFixers/PreferStructuredCodeWriterIfBlockCodeFixProvider.cs +++ b/src/src/SourceGeneratorFramework.CodeFixers/PreferStructuredCodeWriterIfBlockCodeFixProvider.cs @@ -146,7 +146,7 @@ static SeparatedSyntaxList<ArgumentSyntax> CreateSeparatedList(List<ArgumentSynt if (arguments.Count == 0) return SyntaxFactory.SeparatedList<ArgumentSyntax>(); - List<SyntaxNodeOrToken> nodesAndTokens = new((arguments.Count * 2) - 1); + List<SyntaxNodeOrToken> nodesAndTokens = [with((arguments.Count * 2) - 1)]; for (var index = 0; index < arguments.Count; index++) { if (index > 0) diff --git a/src/src/SourceGeneratorFramework.CodeFixers/PreferStructuredCodeWriterStatementCodeFixProvider.cs b/src/src/SourceGeneratorFramework.CodeFixers/PreferStructuredCodeWriterStatementCodeFixProvider.cs new file mode 100644 index 0000000..a3f0888 --- /dev/null +++ b/src/src/SourceGeneratorFramework.CodeFixers/PreferStructuredCodeWriterStatementCodeFixProvider.cs @@ -0,0 +1,352 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Purview.SourceGeneratorFramework.Analyzers; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +/// <summary> +/// Rewrites a raw <c>CodeWriter</c> text call that writes a single statement to the matching structured +/// statement API, such as <c>writer.Line("return value;")</c> to <c>writer.Return("value")</c> +/// (<c>PSGFR19</c>). Statements whose structured API needs a body, such as <c>if</c>, are left for the +/// block-oriented fixers because a single raw line does not describe the body. +/// </summary> +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(PreferStructuredCodeWriterStatementCodeFixProvider))] +public sealed class PreferStructuredCodeWriterStatementCodeFixProvider : CodeFixProvider +{ + internal const string EquivalenceKey = "UseStructuredStatement"; + + public override ImmutableArray<string> FixableDiagnosticIds => ["PSGFR19"]; + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + var semanticModel = await context + .Document.GetSemanticModelAsync(context.CancellationToken) + .ConfigureAwait(false); + if (root is null || semanticModel is null) + return; + + foreach (var diagnostic in context.Diagnostics) + { + var node = root.FindNode(diagnostic.Location.SourceSpan); + if (node is not InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax } invocation) + continue; + + if (invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression is not ExpressionSyntax expression) + continue; + + // The classifier concatenates only the literal parts of an interpolated string, so rewriting one + // with runtime holes would silently drop those values. + if ( + expression is InterpolatedStringExpressionSyntax + && !semanticModel.GetConstantValue(expression, context.CancellationToken).HasValue + ) + continue; + + if ( + !CodeWriterLiteralClassifier.TryGetLiteralText( + expression, + semanticModel, + context.CancellationToken, + out var text + ) || text is null + ) + continue; + + if (!TryGetReplacement(invocation, text, out var replacement, out var actionName)) + continue; + + context.RegisterCodeFix( + CodeAction.Create( + actionName, + _ => Task.FromResult(context.Document.WithSyntaxRoot(root.ReplaceNode(invocation, replacement))), + EquivalenceKey + ), + diagnostic + ); + } + } + + static bool TryGetReplacement( + InvocationExpressionSyntax invocation, + string text, + out InvocationExpressionSyntax replacement, + out string actionName + ) + { + replacement = invocation; + actionName = string.Empty; + + var member = (MemberAccessExpressionSyntax)invocation.Expression; + var receiver = member.Expression; + var trimmed = text.Trim(); + if (trimmed.Length == 0) + return false; + + // An XML documentation comment must keep its /// prefix; Comment() would turn it into a line comment. + if (trimmed.StartsWith("///", StringComparison.Ordinal)) + return false; + + if (trimmed.StartsWith("//", StringComparison.Ordinal)) + { + var comment = trimmed.Substring(2).TrimStart(); + return Build( + receiver, + invocation, + member, + "Comment", + "Use Comment()", + [StringLiteral(comment)], + out replacement, + out actionName + ); + } + + if ( + TryUseDirective(trimmed, out var usingApi, out var usingArguments, out var usingAction) + && Build( + receiver, + invocation, + member, + usingApi, + usingAction, + usingArguments, + out replacement, + out actionName + ) + ) + return true; + + if (trimmed.StartsWith("return ", StringComparison.Ordinal) && trimmed.EndsWith(";", StringComparison.Ordinal)) + { + var expression = trimmed.Substring("return ".Length, trimmed.Length - "return ".Length - 1).Trim(); + return Build( + receiver, + invocation, + member, + "Return", + "Use Return()", + [StringLiteral(expression)], + out replacement, + out actionName + ); + } + + if (trimmed == "return;") + return Build(receiver, invocation, member, "Return", "Use Return()", [], out replacement, out actionName); + + if (trimmed.StartsWith("throw ", StringComparison.Ordinal) && trimmed.EndsWith(";", StringComparison.Ordinal)) + { + var expression = trimmed.Substring("throw ".Length, trimmed.Length - "throw ".Length - 1).Trim(); + return Build( + receiver, + invocation, + member, + "Throw", + "Use Throw()", + [StringLiteral(expression)], + out replacement, + out actionName + ); + } + + var isAwaited = trimmed.StartsWith("await ", StringComparison.Ordinal); + var statement = isAwaited ? trimmed.Substring("await ".Length).Trim() : trimmed; + if (!statement.EndsWith(";", StringComparison.Ordinal)) + return false; + + var expressionText = statement.Substring(0, statement.Length - 1).Trim(); + if (expressionText.Length == 0) + return false; + + var parsed = SyntaxFactory.ParseExpression(expressionText); + if (isAwaited) + { + if (parsed is not InvocationExpressionSyntax awaitedCall) + return false; + + // The awaited call is a separate expression from the invocation, so we need to build the replacement from it instead of the original invocation. + return BuildCall( + receiver, + invocation, + member, + awaitedCall, + isAwaited: true, + out replacement, + out actionName + ); + } + + if (parsed is InvocationExpressionSyntax call) + { + return BuildCall(receiver, invocation, member, call, isAwaited: false, out replacement, out actionName); + } + + if (parsed is AssignmentExpressionSyntax { RawKind: (int)SyntaxKind.SimpleAssignmentExpression } assignment) + { + return Build( + receiver, + invocation, + member, + "Assignment", + "Use Assignment()", + [StringLiteral(assignment.Left.ToString()), StringLiteral(assignment.Right.ToString())], + out replacement, + out actionName + ); + } + + // A simple expression that is not a method call or assignment can be treated as a method call with no arguments. + return false; + } + + static bool TryUseDirective(string trimmed, out string api, out ExpressionSyntax[] arguments, out string actionName) + { + api = string.Empty; + arguments = []; + actionName = string.Empty; + + if (!trimmed.EndsWith(";", StringComparison.Ordinal)) + return false; + + if (trimmed.StartsWith("global using ", StringComparison.Ordinal)) + { + var globalTarget = trimmed + .Substring("global using ".Length, trimmed.Length - "global using ".Length - 1) + .Trim(); + if (globalTarget.Length == 0 || globalTarget.StartsWith("static ", StringComparison.Ordinal)) + return false; + + api = "Using"; + arguments = [StringLiteral(globalTarget), BooleanLiteral(true)]; + actionName = "Use Using()"; + return true; + } + + if (!trimmed.StartsWith("using ", StringComparison.Ordinal)) + return false; + + var body = trimmed.Substring("using ".Length, trimmed.Length - "using ".Length - 1).Trim(); + if (body.Length == 0 || body.StartsWith("static ", StringComparison.Ordinal)) + return false; + + var equalsIndex = body.IndexOf(" = ", StringComparison.Ordinal); + if (equalsIndex < 0) + { + api = "Using"; + arguments = [StringLiteral(body)]; + actionName = "Use Using()"; + return true; + } + + var alias = body.Substring(0, equalsIndex).Trim(); + var target = body.Substring(equalsIndex + " = ".Length).Trim(); + if (alias.Length == 0 || target.Length == 0) + return false; + + api = "UsingAlias"; + arguments = [StringLiteral(alias), StringLiteral(target)]; + actionName = "Use UsingAlias()"; + return true; + } + + static bool BuildCall( + ExpressionSyntax receiver, + InvocationExpressionSyntax invocation, + MemberAccessExpressionSyntax member, + InvocationExpressionSyntax call, + bool isAwaited, + out InvocationExpressionSyntax replacement, + out string actionName + ) + { + replacement = invocation; + actionName = string.Empty; + + string? callReceiver; + string methodName; + switch (call.Expression) + { + case IdentifierNameSyntax identifier: + callReceiver = null; + methodName = identifier.Identifier.Text; + break; + case GenericNameSyntax generic: + callReceiver = null; + methodName = generic.ToString(); + break; + case MemberAccessExpressionSyntax access: + callReceiver = access.Expression.ToString(); + methodName = access.Name.ToString(); + break; + default: + return false; + } + + var api = (isAwaited, callReceiver is null) switch + { + (true, true) => "AwaitedMethodCall", + (true, false) => "AwaitedMethodCallOn", + (false, true) => "MethodCall", + (false, false) => "MethodCallOn", + }; + + List<ExpressionSyntax> arguments = []; + if (callReceiver is not null) + arguments.Add(StringLiteral(callReceiver)); + arguments.Add(StringLiteral(methodName)); + foreach (var argument in call.ArgumentList.Arguments) + arguments.Add(StringLiteral(argument.ToString())); + + return Build( + receiver, + invocation, + member, + api, + $"Use {api}()", + [.. arguments], + out replacement, + out actionName + ); + } + + static bool Build( + ExpressionSyntax receiver, + InvocationExpressionSyntax invocation, + MemberAccessExpressionSyntax member, + string api, + string action, + ExpressionSyntax[] arguments, + out InvocationExpressionSyntax replacement, + out string actionName + ) + { + var newMemberAccess = SyntaxFactory + .MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + receiver, + SyntaxFactory.IdentifierName(api) + ) + .WithTriviaFrom(member); + + var newArguments = SyntaxFactory.ArgumentList( + SyntaxFactory.SeparatedList(arguments.Select(SyntaxFactory.Argument)) + ); + + replacement = SyntaxFactory.InvocationExpression(newMemberAccess, newArguments).WithTriviaFrom(invocation); + actionName = action; + return true; + } + + static LiteralExpressionSyntax StringLiteral(string value) => + SyntaxFactory.LiteralExpression(SyntaxKind.StringLiteralExpression, SyntaxFactory.Literal(value)); + + static LiteralExpressionSyntax BooleanLiteral(bool value) => + SyntaxFactory.LiteralExpression(value ? SyntaxKind.TrueLiteralExpression : SyntaxKind.FalseLiteralExpression); +} diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/README.md b/src/src/SourceGeneratorFramework.ExampleGenerator/README.md index 8085209..3dbee9d 100644 --- a/src/src/SourceGeneratorFramework.ExampleGenerator/README.md +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/README.md @@ -27,7 +27,7 @@ Reference implementation of an incremental C# source generator built with `Purvi - Incremental pipeline with `IncrementalPipeline.ForAttributeWithMetadataName`. - Attribute-data model (`GenerateServiceAttributeData`) generated by `AttributeDataModelGenerator` using `[Argument]` for a constructor parameter and `[Property]` for a named property. - Enum string extraction from `TypedConstant` values (`IsEnum = true`). -- `CodeWriter` usage for all output, including post-initialization sources and attribute/enum declarations. See the [CodeWriter API reference](../../docs/wiki/Code-Writer.md) for the structured API and best practices. +- `CodeWriter` usage for all output, including post-initialization sources and attribute/enum declarations. See the [CodeWriter API reference](../../docs/wiki/authoring/Code-Writer.md) for the structured API and best practices. - A dedicated `CodeWriterSampleGenerator` that emits a best-practice sample class for every `[GenerateCodeWriterSample]` target, demonstrating minimal overloads, structured statements, scope usage, and `NetConditionalReturn`. - `TypeIdentity` / `TypeReference` / `TypeLibrary` helpers for safe type/namespace references. - Diagnostic reporting for invalid inputs (e.g. interfaces, static classes, nested classes, abstract classes). diff --git a/src/src/SourceGeneratorFramework.Generators/AttributeDataModelGenerator.cs b/src/src/SourceGeneratorFramework.Generators/AttributeDataModelGenerator.cs index 1395c68..1d546b5 100644 --- a/src/src/SourceGeneratorFramework.Generators/AttributeDataModelGenerator.cs +++ b/src/src/SourceGeneratorFramework.Generators/AttributeDataModelGenerator.cs @@ -826,6 +826,7 @@ static string ToLocalName(string propertyName) if (string.Equals(name, "attributeData", StringComparison.Ordinal)) return "__attributeData"; + // Prefixing keywords with @ is valid C# syntax, but it is not idiomatic. The generator uses the property name as-is when it is not a keyword, and prefixes it with @ when it is a keyword. return SyntaxFacts.GetKeywordKind(name) != SyntaxKind.None || SyntaxFacts.GetContextualKeywordKind(name) != SyntaxKind.None diff --git a/src/src/SourceGeneratorFramework.Generators/Helpers/AttributeDataModelLibrary.cs b/src/src/SourceGeneratorFramework.Generators/Helpers/AttributeDataModelLibrary.cs index e948acb..790cb99 100644 --- a/src/src/SourceGeneratorFramework.Generators/Helpers/AttributeDataModelLibrary.cs +++ b/src/src/SourceGeneratorFramework.Generators/Helpers/AttributeDataModelLibrary.cs @@ -183,7 +183,7 @@ out var resolvedIdentity ); } - HashSet<string> excludedNames = new(StringComparer.Ordinal); + HashSet<string> excludedNames = [with(StringComparer.Ordinal)]; var explicitProperties = ReadExplicitProperties( structSymbol, excludedNames, @@ -628,7 +628,7 @@ CancellationToken cancellationToken return []; var discovered = ImmutableArray.CreateBuilder<AttributeDataModelProperty>(); - HashSet<string> discoveredNames = new(StringComparer.Ordinal); + HashSet<string> discoveredNames = [with(StringComparer.Ordinal)]; foreach (var constructor in namedType.InstanceConstructors) { diff --git a/src/src/SourceGeneratorFramework.Generators/Helpers/TypeLibraryModelLibrary.cs b/src/src/SourceGeneratorFramework.Generators/Helpers/TypeLibraryModelLibrary.cs index e92d9f8..8dba0e7 100644 --- a/src/src/SourceGeneratorFramework.Generators/Helpers/TypeLibraryModelLibrary.cs +++ b/src/src/SourceGeneratorFramework.Generators/Helpers/TypeLibraryModelLibrary.cs @@ -80,7 +80,7 @@ CancellationToken cancellationToken var specDocumentation = ExtractDocumentation(specSymbol); List<NamespaceNodeBuilder> root = []; - Dictionary<string, NamespaceNodeBuilder> nodeLookup = new(StringComparer.Ordinal); + Dictionary<string, NamespaceNodeBuilder> nodeLookup = [with(StringComparer.Ordinal)]; // The generated type library inherits the full framework PurviewTypeLibrary shape, so every // framework member is present before the user's [TypeRef] members are merged in. The shape was @@ -434,7 +434,7 @@ static EquatableArray<TypeLibraryNamespaceNode> BuildFrameworkTree(Compilation c return EquatableArray<TypeLibraryNamespaceNode>.Empty; List<NamespaceNodeBuilder> root = []; - Dictionary<string, NamespaceNodeBuilder> lookup = new(StringComparer.Ordinal); + Dictionary<string, NamespaceNodeBuilder> lookup = [with(StringComparer.Ordinal)]; foreach (var nestedType in frameworkType.GetTypeMembers()) AddFrameworkNode(root, lookup, nestedType); diff --git a/src/src/SourceGeneratorFramework.Generators/README.md b/src/src/SourceGeneratorFramework.Generators/README.md index 4aeafee..349607d 100644 --- a/src/src/SourceGeneratorFramework.Generators/README.md +++ b/src/src/SourceGeneratorFramework.Generators/README.md @@ -27,7 +27,7 @@ The generator emits marker attributes into your compilation: - `[GenericTypeArgument(int index)]` — generic type argument by position. - `[GenericTypeArgument(string name)]` — generic type argument by type parameter name. -Names are looked up case-sensitively at run time, and a name that is not found yields the default value, so a mapping mistake compiles cleanly. `AttributeDataModelNamingAnalyzer` (`ADM0011`–`ADM0015`) reports case-only mismatches, named sources that are not public settable members, generated-member name clashes, and `[NestedModel]` mappings the outer attribute does not line up with. See the [naming rules](../../../docs/wiki/Attribute-Data-Models.md#naming-rules). +Names are looked up case-sensitively at run time, and a name that is not found yields the default value, so a mapping mistake compiles cleanly. `AttributeDataModelNamingAnalyzer` (`ADM0011`–`ADM0015`) reports case-only mismatches, named sources that are not public settable members, generated-member name clashes, and `[NestedModel]` mappings the outer attribute does not line up with. See the [naming rules](../../../docs/wiki/authoring/Attribute-Data-Models.md#naming-rules). ### Default values @@ -207,7 +207,7 @@ Consumers then reference `MyTypeLibrary.GenerateMyAttribute`, `MyTypeLibrary.Microsoft.Extensions.Logging.ILogger`, and `MyTypeLibrary.System.Collections.Generic.Items` directly. -See [docs/wiki/Type-Library.md](../../docs/wiki/Type-Library.md) for the full DSL and the member-accessibility rules. +See [docs/wiki/authoring/Type-Library.md](../../docs/wiki/authoring/Type-Library.md) for the full DSL and the member-accessibility rules. ## License diff --git a/src/src/SourceGeneratorFramework.MergeTool/MergeToolRunner.cs b/src/src/SourceGeneratorFramework.MergeTool/MergeToolRunner.cs index 276f28b..8132810 100644 --- a/src/src/SourceGeneratorFramework.MergeTool/MergeToolRunner.cs +++ b/src/src/SourceGeneratorFramework.MergeTool/MergeToolRunner.cs @@ -67,11 +67,12 @@ public static int Run(string[] args, TextWriter error, ILogger? logger = null, T Directory.CreateDirectory(stagingDirectory); var stagingPath = Path.Combine(stagingDirectory, Path.GetFileName(outputPath)); - HashSet<string> searchDirectories = new(StringComparer.OrdinalIgnoreCase) - { + HashSet<string> searchDirectories = + [ + with(StringComparer.OrdinalIgnoreCase), Path.GetDirectoryName(componentPath)!, Path.GetDirectoryName(frameworkPath)!, - }; + ]; foreach (var searchPath in options.SearchPaths) { diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/AttributeDataAssertions.cs b/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/AttributeDataAssertions.cs index a54a4f1..d296ba4 100644 --- a/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/AttributeDataAssertions.cs +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/AttributeDataAssertions.cs @@ -163,6 +163,7 @@ static AssertionResult<AttributeArgument> Check( if (form is not null && argument.Form != form) return AssertionResult.Failed($"'{argument.Name}' was supplied as {argument.Form} in {arguments}"); + // The argument matches the expected value and form, so the assertion passes. return AssertionResult<AttributeArgument>.Passed(argument); } } diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/README.md b/src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/README.md index 9054a52..5f73f99 100644 --- a/src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/README.md +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/README.md @@ -165,7 +165,7 @@ for you. To assert a nullable expected type, use the test-only `query.MakeNullable(...)` extension: it resolves the annotation against the query's compilation and, unlike `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, -does not trigger the `PSGFR16` context-overload suggestion (tests have no generation context to pass). +does not trigger the `PSGFR16` context-overload warning (tests have no generation context to pass). ```csharp var query = result.Generated(); diff --git a/src/src/SourceGeneratorFramework.Testing/AttributeArgument.cs b/src/src/SourceGeneratorFramework.Testing/AttributeArgument.cs index b82b30b..f2b12b4 100644 --- a/src/src/SourceGeneratorFramework.Testing/AttributeArgument.cs +++ b/src/src/SourceGeneratorFramework.Testing/AttributeArgument.cs @@ -51,7 +51,7 @@ public override string ToString() AttributeArgumentForm.NamedArgument => $"{Name} = {value}", AttributeArgumentForm.Defaulted => $"#{Ordinal} {Name}: {value} (defaulted)", AttributeArgumentForm.NamedParameter => $"#{Ordinal} {Name}: {value} (by name)", - _ => $"#{Ordinal} {Name}: {value}", + AttributeArgumentForm.Positional or _ => $"#{Ordinal} {Name}: {value}", }; } @@ -95,6 +95,8 @@ internal static string FormatValue(TypedConstant value) if (value.IsNull) return "null"; + // Primitive and enum values, formatted as C# literals. +#pragma warning disable IDE0072 // Add missing cases return value.Kind switch { TypedConstantKind.Array => $"[{string.Join(", ", value.Values.Select(FormatValue))}]", @@ -110,6 +112,7 @@ internal static string FormatValue(TypedConstant value) var other => ToInvariantString(other), }, }; +#pragma warning restore IDE0072 // Add missing cases } static string? EnumMemberName(TypedConstant value) @@ -149,6 +152,7 @@ static string MetadataName(ITypeSymbol type) if (type.ContainingType is { } containing) return MetadataName(containing) + "+" + type.MetadataName; + // The containing namespace is null for a global namespace type, and the display string is empty for a global namespace. return type.ContainingNamespace is { IsGlobalNamespace: false } ns ? ns.ToDisplayString() + "." + type.MetadataName : type.MetadataName; @@ -163,6 +167,7 @@ static bool NumericEquals(object? actual, object expected) if (!IsNumeric(actual) || !IsNumeric(expected)) return false; + // Floating-point values compare as double: decimal cannot hold their range. return actual is float or double || expected is float or double ? Convert.ToDouble(actual, CultureInfo.InvariantCulture) == Convert.ToDouble(expected, CultureInfo.InvariantCulture) diff --git a/src/src/SourceGeneratorFramework.Testing/AttributeArguments.cs b/src/src/SourceGeneratorFramework.Testing/AttributeArguments.cs index bed09e0..f253535 100644 --- a/src/src/SourceGeneratorFramework.Testing/AttributeArguments.cs +++ b/src/src/SourceGeneratorFramework.Testing/AttributeArguments.cs @@ -60,7 +60,7 @@ public static AttributeArguments From(AttributeData attribute, CancellationToken var syntax = attribute.ApplicationSyntaxReference?.GetSyntax(cancellationToken) as AttributeSyntax; var positionalCount = 0; - HashSet<string> namedParameters = new(StringComparer.Ordinal); + HashSet<string> namedParameters = [with(StringComparer.Ordinal)]; if (syntax?.ArgumentList is { } argumentList) { foreach (var argument in argumentList.Arguments) diff --git a/src/src/SourceGeneratorFramework.Testing/EmittedAssembly.cs b/src/src/SourceGeneratorFramework.Testing/EmittedAssembly.cs index 75e44b7..42c1bcf 100644 --- a/src/src/SourceGeneratorFramework.Testing/EmittedAssembly.cs +++ b/src/src/SourceGeneratorFramework.Testing/EmittedAssembly.cs @@ -105,7 +105,7 @@ public MetadataView(byte[] bytes, Compilation compilation) static HashSet<string> MetadataPaths(Compilation compilation) { - HashSet<string> paths = new(StringComparer.OrdinalIgnoreCase); + HashSet<string> paths = [with(StringComparer.OrdinalIgnoreCase)]; paths.UnionWith(SourceGeneratorHelpers.TrustedAssemblies); foreach (var reference in compilation.References.OfType<PortableExecutableReference>()) diff --git a/src/src/SourceGeneratorFramework.Testing/RoslynTestRunner.cs b/src/src/SourceGeneratorFramework.Testing/RoslynTestRunner.cs index 2639df7..047c783 100644 --- a/src/src/SourceGeneratorFramework.Testing/RoslynTestRunner.cs +++ b/src/src/SourceGeneratorFramework.Testing/RoslynTestRunner.cs @@ -41,10 +41,7 @@ SourceGeneratorTestOptions options { return compilation.WithAnalyzers( analyzers, - new AnalyzerOptions( - ImmutableArray<AdditionalText>.Empty, - new TestAnalyzerConfigOptionsProvider(analyzerConfigOptions) - ) + new AnalyzerOptions([], new TestAnalyzerConfigOptionsProvider(analyzerConfigOptions)) ); } } diff --git a/src/src/SourceGeneratorFramework.Testing/Sdk/README.md b/src/src/SourceGeneratorFramework.Testing/Sdk/README.md index a8fc3e3..2401ece 100644 --- a/src/src/SourceGeneratorFramework.Testing/Sdk/README.md +++ b/src/src/SourceGeneratorFramework.Testing/Sdk/README.md @@ -230,7 +230,7 @@ Scoped results also expose node-inspection checks through `MemberQueryExtensions Tests asserting a nullable expected type can use the test-only `query.MakeNullable(type)` extension on a `CodeQuery` (it accepts a `TypeReference` or `TypeIdentity`). It resolves the annotation against the query's compilation and, unlike `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not trigger the -`PSGFR16` context-overload suggestion — tests have no generation context to pass. +`PSGFR16` context-overload warning — tests have no generation context to pass. ```csharp var query = result.Generated(); @@ -331,7 +331,7 @@ cache tests live in the `Purview.SourceGeneratorFramework` source repository — `SourceGeneratorShared.UnitTests/IncrementalPipelineCacheTests` (framework stages), `SourceGeneratorFramework.ExampleGenerator.UnitTests/StepCacheTests` (the canonical golden-matrix sample), and `.../ServiceRegistrationCacheTests` (an end-to-end generator) — and should be replicated into your own -test project rather than copied from the package. See `docs/wiki/Step-Cache-Tests.md` for the full walkthrough. +test project rather than copied from the package. See `docs/wiki/testing/Step-Cache-Tests.md` for the full walkthrough. ## Documentation diff --git a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorHelpers.cs b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorHelpers.cs index f744850..22a8da6 100644 --- a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorHelpers.cs +++ b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorHelpers.cs @@ -80,7 +80,7 @@ Assembly generatorAssembly // References are shared instances now, so the same assembly named twice yields the same object. // Compilation.Create tolerates that, but Solution.AddMetadataReferences (the analyzer, code-fix and // refactoring path) rejects duplicate items, so the additions are deduplicated here. - HashSet<string> addedPaths = new(StringComparer.OrdinalIgnoreCase); + HashSet<string> addedPaths = [with(StringComparer.OrdinalIgnoreCase)]; foreach (var type in options.AdditionalAssemblyTypes) { var location = type.Assembly.Location; diff --git a/src/src/SourceGeneratorFramework/Sdk/README.md b/src/src/SourceGeneratorFramework/Sdk/README.md index 5085cd1..ea4d1d3 100644 --- a/src/src/SourceGeneratorFramework/Sdk/README.md +++ b/src/src/SourceGeneratorFramework/Sdk/README.md @@ -238,7 +238,7 @@ writer.Type(TypeIdentity.Create<string>().MakeNullable()); // "string" when null Composition and rendering both resolve `NullableDirectiveMode` together with `IsNullableContextEnabled`, so an `Always` mode keeps the `?` even for a nullable-disabled target, and a `Disable` mode strips it even when the target enables nullable. -The analyzer `PSGFR16` (suggestion) flags bare `Nullable()`/`MakeNullable()` calls and its code fix passes the first in-scope `CodeWriter` or `GenerationSettings`, including project-wide "Fix all" support. +The analyzer `PSGFR16` (warning) flags bare `Nullable()`/`MakeNullable()` calls and its code fix passes the first in-scope `CodeWriter` or `GenerationSettings`, including project-wide "Fix all" support. ### Comparing references with or without annotations @@ -596,12 +596,14 @@ accessors, expression bodies, and callback-generated getter/setter bodies. Struc constructors return a disposable body scope; callback overloads are available when a complete member should be written in one call. -`TypeDeclarationOptions.Kind` supports classes, structs, record classes, record structs, -interfaces, enums, and delegates. The matching `Interface`, `Enum`, and `Delegate` -helpers set the kind automatically. Interface inheritance is supplied through `Interfaces`, enums -can specify `EnumUnderlyingType`, and delegates use `DelegateReturnType` and -`DelegateParameters`. Generic delegate and interface constraints use the existing `GenericTypes` -model. +`TypeDeclarationOptions.Kind` supports classes, structs, record classes, record structs, C# 15 +unions, interfaces, enums, and delegates. The matching `Union`, `Interface`, `Enum`, and `Delegate` +helpers set the kind automatically. A union supplies its case types through `UnionCaseTypes` (for +example `writer.Union("Pet", [cat, dog])`); it rejects a base type and primary-constructor parameters +because the case-type list occupies that position. Interface inheritance is supplied through +`Interfaces`, enums can specify `EnumUnderlyingType`, and delegates use `DelegateReturnType` and +`DelegateParameters`. Generic delegate, interface, and union constraints use the existing +`GenericTypes` model. Attributes and parameters are structured as well; raw declaration fragments are not accepted: @@ -672,6 +674,11 @@ Set `TypeDeclarationOptions.IsAbstract` for abstract classes or record classes. over the default `IsSealed = true`, so callers do not need to disable sealing explicitly. Abstract static classes and abstract non-class declarations are rejected. +Set `TypeDeclarationOptions.IsClosed` on a class or record class to emit the C# 15 `closed` modifier. +A closed type is implicitly abstract, so it rejects `IsStatic` and `IsAbstract`, and `IsSealed` is +ignored rather than emitted alongside it. `TypeHelpers.CreatePartialTypeDeclarationOptions` preserves +`closed` when it reopens a type the compiler marked with `IsClosedTypeAttribute`. + Roslyn accessibility values can be converted in both directions: ```csharp @@ -1007,13 +1014,13 @@ The `Purview.SourceGeneratorFramework` package includes the `Purview.SourceGener | `PSGFR12` | Use `IIncrementalGenerator` / `RegisterSourceOutput` instead of `ISourceGenerator`. | | `PSGFR14` | Avoid `RegisterImplementationSourceOutput` unless implementation-only output is required. | | `PSGFR15` | Pipeline model collection members should use sequence equality (e.g. `EquatableArray<T>`). | -| `PSGFR16` | Prefer the nullable-context `Nullable()`/`MakeNullable()` overload so annotations honour the target compilation. | +| `PSGFR16` | Prefer the nullable-context `Nullable()`/`MakeNullable()` overload so annotations honour the target compilation (warning; has a code fix). | | `PSGFR17` | Consume `CodeWriter` scope-returning methods (`...Scope`, `IndentedScope`) with `using`. | | `PSGFR18` | Prefer structured declaration APIs (`Class`, `Method`, `Property`, `Field`) over raw declaration text. | -| `PSGFR19` | Prefer structured statement APIs (`Return`, `MethodCall`, `Throw`, `Assignment`, `Using`, `Comment`) over raw statement text. | -| `PSGFR20` | Prefer the minimal `CodeWriter` overloads over constructing `*DeclarationOptions` values manually. | +| `PSGFR19` | Prefer structured statement APIs (`Return`, `MethodCall`, `Throw`, `Assignment`, `Using`, `Comment`) over raw statement text (warning; has a code fix for single-line statements). | +| `PSGFR20` | Prefer the minimal `CodeWriter` overloads over constructing `*DeclarationOptions` values manually (warning; has a code fix). | | `PSGFR21` | Prefer `HashDefines`/`HashDefinesScope` for `#if`/`#endif` conditional-compilation directives. | -| `PSGFR22` | Prefer `PragmaDisable`/`OpenPragmasScope` for `#pragma warning` directives. | +| `PSGFR22` | Prefer `PragmaDisable`/`OpenPragmasScope` for `#pragma warning` directives (warning; `disable` has a code fix). | | `PSGFR23` | Prefer structured `IfBlock`/`ElseIf`/`Else` over raw `if` block text. | | `PSGFR24` | `CodeFixProvider` is not marked `[ExportCodeFixProvider]`; Visual Studio will never discover it. | | `PSGFR25` | `DiagnosticAnalyzer` is not marked `[DiagnosticAnalyzer]`; it will never run. | diff --git a/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj b/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj index e4b57eb..4623602 100644 --- a/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj +++ b/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj @@ -49,7 +49,7 @@ The packaged README links Packaging.md; the wiki page is the single source of truth and must ship so the relative link resolves inside the package. --> - <None Include="..\..\..\docs\wiki\Packaging.md" Link="Sdk\Packaging.md" Pack="true" Visible="true" /> + <None Include="..\..\..\docs\wiki\operations\Packaging.md" Link="Sdk\Packaging.md" Pack="true" Visible="true" /> </ItemGroup> <!-- diff --git a/src/src/SourceGeneratorShared/CodeWriter.DeclarationOverloads.cs b/src/src/SourceGeneratorShared/CodeWriter.DeclarationOverloads.cs index 68440e2..28ba704 100644 --- a/src/src/SourceGeneratorShared/CodeWriter.DeclarationOverloads.cs +++ b/src/src/SourceGeneratorShared/CodeWriter.DeclarationOverloads.cs @@ -736,6 +736,91 @@ Action<CodeWriter> writeBody return RecordStruct(declaration, writeBody); } + /// <summary> + /// Writes a C# 15 union declaration using the minimal identifying properties and returns its body + /// scope. + /// </summary> + /// <param name="name">The union name.</param> + /// <param name="caseTypes">The case types written between the type name and the base list.</param> + /// <param name="accessibility">The optional accessibility.</param> + /// <param name="configure">An optional callback that configures the declaration.</param> + /// <returns>The union body scope.</returns> + /// <example><code>using (writer.UnionScope("Pet", [cat, dog])) { }</code></example> + public BlockScope UnionScope( + string name, + IEnumerable<TypeReference> caseTypes, + TypeDeclarationAccessibility? accessibility = null, + Func<TypeDeclarationOptions, TypeDeclarationOptions>? configure = null + ) + { + if (caseTypes is null) + throw new ArgumentNullException(nameof(caseTypes)); + + TypeDeclarationOptions declaration = new(name, accessibility) { UnionCaseTypes = [.. caseTypes] }; + if (configure is not null) + declaration = configure(declaration); + + return UnionScope(declaration); + } + + /// <summary> + /// Writes an empty C# 15 union declaration using the minimal identifying properties, terminated with a + /// semicolon instead of a body. + /// </summary> + /// <param name="name">The union name.</param> + /// <param name="caseTypes">The case types written between the type name and the base list.</param> + /// <param name="accessibility">The optional accessibility.</param> + /// <param name="configure">An optional callback that configures the declaration.</param> + /// <returns>The current writer.</returns> + /// <example><code>writer.Union("Pet", [cat, dog]); // public partial union Pet(Cat, Dog);</code></example> + public CodeWriter Union( + string name, + IEnumerable<TypeReference> caseTypes, + TypeDeclarationAccessibility? accessibility = null, + Func<TypeDeclarationOptions, TypeDeclarationOptions>? configure = null + ) + { + if (caseTypes is null) + throw new ArgumentNullException(nameof(caseTypes)); + + TypeDeclarationOptions declaration = new(name, accessibility) { UnionCaseTypes = [.. caseTypes] }; + if (configure is not null) + declaration = configure(declaration); + + return Union(declaration); + } + + /// <summary> + /// Writes a C# 15 union declaration using the minimal identifying properties and invokes a callback + /// for its body. + /// </summary> + /// <param name="name">The union name.</param> + /// <param name="caseTypes">The case types written between the type name and the base list.</param> + /// <param name="accessibility">The optional accessibility.</param> + /// <param name="configure">An optional callback that configures the declaration, or <see langword="null"/> for defaults.</param> + /// <param name="writeBody">The action that writes the union body.</param> + /// <returns>The current writer.</returns> + /// <example><code>writer.Union("Pet", [cat, dog], TypeDeclarationAccessibility.Public, null, _ => { });</code></example> + public CodeWriter Union( + string name, + IEnumerable<TypeReference> caseTypes, + TypeDeclarationAccessibility? accessibility, + Func<TypeDeclarationOptions, TypeDeclarationOptions>? configure, + Action<CodeWriter> writeBody + ) + { + if (caseTypes is null) + throw new ArgumentNullException(nameof(caseTypes)); + if (writeBody is null) + throw new ArgumentNullException(nameof(writeBody)); + + TypeDeclarationOptions declaration = new(name, accessibility) { UnionCaseTypes = [.. caseTypes] }; + if (configure is not null) + declaration = configure(declaration); + + return Union(declaration, writeBody); + } + /// <summary> /// Writes an interface declaration using the minimal identifying properties and returns its body /// scope. diff --git a/src/src/SourceGeneratorShared/CodeWriter.cs b/src/src/SourceGeneratorShared/CodeWriter.cs index c61b734..f0b21c3 100644 --- a/src/src/SourceGeneratorShared/CodeWriter.cs +++ b/src/src/SourceGeneratorShared/CodeWriter.cs @@ -1756,6 +1756,48 @@ declaration is null ? throw new ArgumentNullException(nameof(declaration)) : TypeDeclaration(declaration with { Kind = TypeDeclarationKind.RecordStruct }); + /// <summary> + /// Writes a C# 15 union declaration from structured options and returns its body scope. + /// </summary> + /// <param name="declaration">The union declaration options. <see cref="TypeDeclarationOptions.UnionCaseTypes"/> must contain at least one case type.</param> + /// <returns>The union body scope.</returns> + /// <example><code>using (writer.UnionScope(new TypeDeclarationOptions("Pet") { UnionCaseTypes = [cat, dog, bird] })) { }</code></example> + public BlockScope UnionScope(TypeDeclarationOptions declaration) + { + return declaration is null + ? throw new ArgumentNullException(nameof(declaration)) + : TypeScope(declaration with { Kind = TypeDeclarationKind.Union }); + } + + /// <summary> + /// Writes a C# 15 union declaration from structured options and invokes a callback for its body. + /// </summary> + /// <param name="declaration">The union declaration options.</param> + /// <param name="bodyWriter">The action that writes the body of the union.</param> + /// <returns>The current writer.</returns> + /// <example><code>writer.Union(new TypeDeclarationOptions("Pet") { UnionCaseTypes = [cat, dog] }, _ => { });</code></example> + public CodeWriter Union(TypeDeclarationOptions declaration, Action<CodeWriter> bodyWriter) + { + if (bodyWriter is null) + throw new ArgumentNullException(nameof(bodyWriter)); + + using (UnionScope(declaration)) + bodyWriter(this); + + return this; + } + + /// <summary> + /// Writes an empty C# 15 union declaration, terminated with a semicolon instead of a body. + /// </summary> + /// <param name="declaration">The union declaration options.</param> + /// <returns>The current writer.</returns> + /// <example><code>writer.Union(new TypeDeclarationOptions("Pet") { UnionCaseTypes = [cat, dog] }); // public partial union Pet(Cat, Dog);</code></example> + public CodeWriter Union(TypeDeclarationOptions declaration) => + declaration is null + ? throw new ArgumentNullException(nameof(declaration)) + : TypeDeclaration(declaration with { Kind = TypeDeclarationKind.Union }); + /// <summary> /// Writes an interface declaration and returns its body scope. /// </summary> @@ -1914,8 +1956,8 @@ public BlockScope TypeScope(TypeDeclarationOptions declaration) /// <summary> /// Writes a structured type declaration with an empty body, terminating it with a semicolon instead of /// opening a block. Produces declarations such as <c>public sealed partial class C;</c> or, with a primary - /// constructor, <c>public sealed class C(string value);</c>. Valid for classes, structs, records and - /// interfaces; delegates always terminate with a semicolon, and enums require a body. + /// constructor, <c>public sealed class C(string value);</c>. Valid for classes, structs, records, + /// interfaces and unions; delegates always terminate with a semicolon, and enums require a body. /// </summary> /// <param name="declaration">The structured type declaration options.</param> /// <returns>The current writer.</returns> @@ -1954,7 +1996,8 @@ bool WriteTypeDeclarationHeader(TypeDeclarationOptions declaration) is TypeDeclarationKind.Class or TypeDeclarationKind.Struct or TypeDeclarationKind.RecordClass - or TypeDeclarationKind.RecordStruct, + or TypeDeclarationKind.RecordStruct + or TypeDeclarationKind.Union, includeEmbeddedAttribute: declaration.IncludeEmbeddedAttribute == true ); } @@ -1964,13 +2007,19 @@ or TypeDeclarationKind.RecordClass if (ResolveAccessibility(declaration.Accessibility, DefaultTypeAccessibility) is { } accessibility) Accessibility(accessibility).Write(' '); - var isStruct = declaration.Kind is TypeDeclarationKind.Struct or TypeDeclarationKind.RecordStruct; + var isStruct = + declaration.Kind + is TypeDeclarationKind.Struct + or TypeDeclarationKind.RecordStruct + or TypeDeclarationKind.Union; var isClass = declaration.Kind is TypeDeclarationKind.Class or TypeDeclarationKind.RecordClass; if (declaration.IsStatic) Write("static "); else if (isStruct && declaration.IsReadOnly) Write("readonly "); + else if (declaration.IsClosed) + Write("closed "); else if (isClass && declaration.IsAbstract) Write("abstract "); else if (isClass && declaration.IsSealed) @@ -1997,6 +2046,7 @@ or TypeDeclarationKind.RecordClass TypeDeclarationKind.RecordStruct => "record struct ", TypeDeclarationKind.Interface => "interface ", TypeDeclarationKind.Enum => "enum ", + TypeDeclarationKind.Union => "union ", TypeDeclarationKind.Delegate => string.Empty, _ => throw new ArgumentOutOfRangeException(nameof(declaration)), } @@ -2006,6 +2056,8 @@ or TypeDeclarationKind.RecordClass GenericTypeParameters(declaration.GenericTypes); if (declaration.Kind == TypeDeclarationKind.Delegate) ParametersWithHeuristic(declaration.DelegateParameters); + else if (declaration.Kind == TypeDeclarationKind.Union) + UnionCaseTypes(declaration.UnionCaseTypes); else ParameterList(declaration.PrimaryConstructorParameters, declaration.ConstructorParametersOnSeparateLines); BaseTypes(declaration); @@ -4203,6 +4255,19 @@ void ParameterList( Write(')'); } + void UnionCaseTypes(ImmutableArray<TypeReference> caseTypes) + { + Write('('); + for (var index = 0; index < caseTypes.Length; index++) + { + if (index != 0) + Write(", "); + + TypeReference(caseTypes[index]); + } + Write(')'); + } + void BaseTypes(TypeDeclarationOptions declaration) { var hasBaseType = declaration.BaseType is { IsEmpty: false }; @@ -4266,7 +4331,11 @@ void GenericConstraints(ImmutableArray<GenericTypeParameterOptions> genericTypes static void ValidateTypeDeclaration(TypeDeclarationOptions declaration) { - var isStruct = declaration.Kind is TypeDeclarationKind.Struct or TypeDeclarationKind.RecordStruct; + var isStruct = + declaration.Kind + is TypeDeclarationKind.Struct + or TypeDeclarationKind.RecordStruct + or TypeDeclarationKind.Union; var supportsPrimaryConstructor = declaration.Kind is TypeDeclarationKind.Class @@ -4287,6 +4356,8 @@ or TypeDeclarationKind.RecordClass ValidateTypeReference(declaration.Interfaces[index], nameof(declaration)); } + ValidateUnionCaseTypes(declaration); + if (declaration.GenericTypes.IsDefaultOrEmpty) return; @@ -4311,6 +4382,31 @@ or TypeDeclarationKind.RecordClass } } + static void ValidateUnionCaseTypes(TypeDeclarationOptions declaration) + { + if (declaration.Kind != TypeDeclarationKind.Union) + { + if (!declaration.UnionCaseTypes.IsDefaultOrEmpty) + throw new ArgumentException( + "UnionCaseTypes is only valid for union declarations.", + nameof(declaration) + ); + return; + } + + if (declaration.UnionCaseTypes.IsDefaultOrEmpty) + throw new ArgumentException("A union declaration requires at least one case type.", nameof(declaration)); + + for (var index = 0; index < declaration.UnionCaseTypes.Length; index++) + { + var caseType = declaration.UnionCaseTypes[index]; + if (caseType.IsEmpty) + throw new ArgumentException("Union case types cannot be empty.", nameof(declaration)); + + ValidateTypeReference(caseType, nameof(declaration)); + } + } + static void ValidateTypeDeclarationModifiers(TypeDeclarationOptions declaration, bool isStruct) { if (declaration.IsStatic && declaration.Kind != TypeDeclarationKind.Class) @@ -4328,6 +4424,8 @@ static void ValidateTypeDeclarationModifiers(TypeDeclarationOptions declaration, if (declaration.IsAbstract && declaration.IsStatic) throw new ArgumentException("A class cannot be both abstract and static.", nameof(declaration)); + ValidateClosedModifier(declaration); + if (declaration.IsStatic && declaration.BaseType is { IsEmpty: false }) throw new ArgumentException("A static class cannot specify a base type.", nameof(declaration)); @@ -4359,6 +4457,24 @@ static void ValidateTypeDeclarationModifiers(TypeDeclarationOptions declaration, throw new ArgumentException("A ref struct cannot specify a base type.", nameof(declaration)); } + static void ValidateClosedModifier(TypeDeclarationOptions declaration) + { + if (!declaration.IsClosed) + return; + + if (declaration.Kind is not TypeDeclarationKind.Class and not TypeDeclarationKind.RecordClass) + throw new ArgumentException("Only class and record class declarations can be closed.", nameof(declaration)); + + if (declaration.IsStatic) + throw new ArgumentException("A closed class cannot be static.", nameof(declaration)); + + if (declaration.IsAbstract) + throw new ArgumentException( + "A closed class is implicitly abstract and cannot also declare abstract.", + nameof(declaration) + ); + } + static void ValidateAdditionalTypeKindOptions(TypeDeclarationOptions declaration, bool supportsPrimaryConstructor) { if ( diff --git a/src/src/SourceGeneratorShared/Helpers/TypeHelpers.cs b/src/src/SourceGeneratorShared/Helpers/TypeHelpers.cs index 7a45b17..e2e1cb9 100644 --- a/src/src/SourceGeneratorShared/Helpers/TypeHelpers.cs +++ b/src/src/SourceGeneratorShared/Helpers/TypeHelpers.cs @@ -634,6 +634,7 @@ bool includeOptionalParts var kind = GetTypeDeclarationKind(containingType); var isStatic = containingType.IsStatic; + var isClosed = includeOptionalParts && IsClosedType(containingType); return new(containingType.Name) { @@ -645,8 +646,9 @@ bool includeOptionalParts : null, IsPartial = true, IsStatic = isStatic, - IsSealed = includeOptionalParts && !isStatic && containingType.IsSealed, - IsAbstract = includeOptionalParts && !isStatic && containingType.IsAbstract, + IsClosed = isClosed, + IsSealed = includeOptionalParts && !isStatic && !isClosed && containingType.IsSealed, + IsAbstract = includeOptionalParts && !isStatic && !isClosed && containingType.IsAbstract, IsReadOnly = containingType.IsReadOnly, GenericTypes = [ @@ -671,6 +673,13 @@ static TypeDeclarationKind GetTypeDeclarationKind(INamedTypeSymbol typeSymbol) = ), }; + static bool IsClosedType(INamedTypeSymbol typeSymbol) => + typeSymbol + .GetAttributes() + .Any(static attribute => + attribute.AttributeClass?.ToDisplayString() == "System.Runtime.CompilerServices.IsClosedTypeAttribute" + ); + static GenericTypeParameterOptions CreateGenericTypeParameterOptions( ITypeParameterSymbol typeParameter, bool includeConstraints diff --git a/src/src/SourceGeneratorShared/TypeDeclarationKind.cs b/src/src/SourceGeneratorShared/TypeDeclarationKind.cs index 7d587fb..aaf8f14 100644 --- a/src/src/SourceGeneratorShared/TypeDeclarationKind.cs +++ b/src/src/SourceGeneratorShared/TypeDeclarationKind.cs @@ -39,4 +39,14 @@ public enum TypeDeclarationKind /// A delegate declaration. /// </summary> Delegate, + + /// <summary> + /// A C# 15 union declaration, such as <c>public union Pet(Cat, Dog);</c>. + /// </summary> + /// <remarks> + /// A union is emitted as a struct-backed declaration with a fixed set of case types. Set + /// <see cref="TypeDeclarationOptions.UnionCaseTypes"/> to the case types; primary-constructor + /// parameters and a base type are not valid for a union declaration. + /// </remarks> + Union, } diff --git a/src/src/SourceGeneratorShared/TypeDeclarationOptions.cs b/src/src/SourceGeneratorShared/TypeDeclarationOptions.cs index 7ff01b1..6a8ceab 100644 --- a/src/src/SourceGeneratorShared/TypeDeclarationOptions.cs +++ b/src/src/SourceGeneratorShared/TypeDeclarationOptions.cs @@ -4,7 +4,7 @@ namespace Purview.SourceGeneratorFramework; /// <summary> -/// Describes a generated class, struct, record, interface, enum, or delegate declaration. +/// Describes a generated class, struct, record, interface, enum, delegate, or union declaration. /// </summary> public sealed record TypeDeclarationOptions { @@ -83,6 +83,13 @@ public TypeDeclarationOptions(TypeIdentity? type, TypeDeclarationAccessibility? /// </remarks> public bool IsAbstract { get; init; } + /// <summary> + /// Gets whether the C# 15 <c>closed</c> modifier is emitted for a class or record class + /// declaration. A closed declaration is implicitly abstract, so it cannot also be static or + /// abstract, and <see cref="IsSealed"/> is ignored. + /// </summary> + public bool IsClosed { get; init; } + /// <summary> /// Gets whether the <c>static</c> modifier is emitted for a class declaration. /// </summary> @@ -132,6 +139,14 @@ public TypeDeclarationOptions(TypeIdentity? type, TypeDeclarationAccessibility? /// </summary> public ImmutableArray<TypeReference> Interfaces { get; init; } = []; + /// <summary> + /// Gets the case types of a C# 15 union declaration, written between the type name and the base + /// list, for example <c>Cat</c> and <c>Dog</c> in <c>public union Pet(Cat, Dog);</c>. Only valid + /// when <see cref="Kind"/> is <see cref="TypeDeclarationKind.Union"/>, and at least one case type + /// is required. + /// </summary> + public ImmutableArray<TypeReference> UnionCaseTypes { get; init; } = []; + /// <summary> /// Gets the generic type parameters and their constraints. /// </summary> diff --git a/src/src/SourceGeneratorShared/TypeSyntaxFacts.cs b/src/src/SourceGeneratorShared/TypeSyntaxFacts.cs index d99bd4b..feb905b 100644 --- a/src/src/SourceGeneratorShared/TypeSyntaxFacts.cs +++ b/src/src/SourceGeneratorShared/TypeSyntaxFacts.cs @@ -53,7 +53,7 @@ public static bool TryGetCore(TypeSyntax? typeSyntax, out TypeSyntax core, out I case ArrayTypeSyntax arrayType: { // `int[][,]` parses as a single node with a rank-specifier list, outermost-first. - modifiers ??= new List<TypeModifier>(arrayType.RankSpecifiers.Count); + modifiers ??= [with(arrayType.RankSpecifiers.Count)]; foreach (var rankSpecifier in arrayType.RankSpecifiers) modifiers.Add(TypeModifier.Array(rankSpecifier.Rank)); diff --git a/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/.editorconfig b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/.editorconfig index a9b39cb..a1e0051 100644 --- a/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/.editorconfig +++ b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/.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/src/tests/Repository.UnitTests/InstalledSkillTests.cs b/src/tests/Repository.UnitTests/InstalledSkillTests.cs index 44339a1..0a0a815 100644 --- a/src/tests/Repository.UnitTests/InstalledSkillTests.cs +++ b/src/tests/Repository.UnitTests/InstalledSkillTests.cs @@ -5,8 +5,9 @@ namespace Purview.SourceGeneratorFramework.Repository; /// <summary> /// Guards the skills installed from other repositories. They are installed by the skills CLI into the /// universal <c>.agents/skills/</c> folder only, and that folder and the CLI's <c>skills-lock.json</c> -/// are committed, so the two must agree and no per-agent copy or instruction file may appear beside -/// them. +/// are committed, so the two must agree and no per-agent copy or instruction file may be committed +/// beside them. A local <c>.claude/</c> folder is gitignored, so its presence is not a repository +/// concern; the ignore rule itself is what the test guards. /// </summary> /// <remarks> /// <c>SkillTests</c> validates this repository's own skills in <c>skills/</c>; these are someone else's, @@ -87,22 +88,25 @@ await Assert } [Test] - public async Task ClaudeFolder_GivenTheInstalledSkills_HoldsNoCopy(CancellationToken cancellationToken) + public async Task ClaudeFolder_GivenLocalAgentSkills_IsIgnoredByGit(CancellationToken cancellationToken) { // Arrange cancellationToken.ThrowIfCancellationRequested(); - var claudeSkills = Path.Combine(RepositoryRoot.Value, ".claude", "skills"); + var ignoreFile = Path.Combine(RepositoryRoot.Value, ".gitignore"); + var lines = await File.ReadAllLinesAsync(ignoreFile, cancellationToken); // Act - var exists = Directory.Exists(claudeSkills); + var ignoresClaudeFolder = lines + .Select(static line => line.Trim()) + .Any(static line => line is "/.claude/" or "/.claude" or ".claude/" or ".claude"); // Assert await Assert - .That(exists) - .IsFalse() + .That(ignoresClaudeFolder) + .IsTrue() .Because( - "skills are installed into .agents/skills/ only. A .claude/skills/ folder means the CLI ran without " - + "`-a universal`; delete it and reinstall with `-a universal`" + ".claude/ is a local, per-agent folder: it must stay gitignored so an installed copy can never " + + "be committed. Keep the `/.claude/` rule in .gitignore." ); } diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferStructuredCodeWriterStatementAnalyzerTests.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferStructuredCodeWriterStatementAnalyzerTests.cs index 18afce9..a932af6 100644 --- a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferStructuredCodeWriterStatementAnalyzerTests.cs +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferStructuredCodeWriterStatementAnalyzerTests.cs @@ -1,3 +1,4 @@ +using Microsoft.CodeAnalysis; using Purview.SourceGeneratorFramework.Testing; using Purview.SourceGeneratorFramework.Testing.TUnit; @@ -11,6 +12,16 @@ public sealed class PreferStructuredCodeWriterStatementAnalyzerTests AdditionalAssemblyTypes = [typeof(CodeWriter), typeof(GenerationSettings)], }; + [Test] + public async Task Rule_GivenStructuredStatementSuggestion_IsWarning() + { + // A suggestion that a consumer is expected to act on must appear as a build warning rather than + // only as an IDE suggestion. + await Assert + .That(PreferStructuredCodeWriterStatementAnalyzer.Rule.DefaultSeverity) + .IsEqualTo(DiagnosticSeverity.Warning); + } + [Test] public async Task Line_WithReturnStatement_ReportsDiagnostic(CancellationToken cancellationToken) { diff --git a/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PreferMinimalCodeWriterOverloadCodeFixProviderTests.cs b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PreferMinimalCodeWriterOverloadCodeFixProviderTests.cs new file mode 100644 index 0000000..aeab274 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PreferMinimalCodeWriterOverloadCodeFixProviderTests.cs @@ -0,0 +1,62 @@ +using Purview.SourceGeneratorFramework.Analyzers; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +public sealed class PreferMinimalCodeWriterOverloadCodeFixProviderTests + : TUnitCodeFixTestBase<PreferMinimalCodeWriterOverloadAnalyzer, PreferMinimalCodeWriterOverloadCodeFixProvider> +{ + static readonly CodeFixTestOptions Options = new() + { + EquivalenceKey = PreferMinimalCodeWriterOverloadCodeFixProvider.EquivalenceKey, + AdditionalAssemblyTypes = [typeof(CodeWriter), typeof(GenerationSettings), typeof(PropertyDeclarationOptions)], + }; + + [Test] + public async Task Property_WithBareOptions_UsesMinimalOverload(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.Property(new PropertyDeclarationOptions("Name", TypeReference.Create<string>(), TypeDeclarationAccessibility.Public)); + } + } + """; + + var result = await ApplyCodeFixAsync(source, Options, cancellationToken); + + await Assert.That(result).HasDiagnostic(PreferMinimalCodeWriterOverloadAnalyzer.Rule.Id); + await Assert + .That(result.FixedSource) + .Contains("writer.Property(\"Name\", TypeReference.Create<string>(), TypeDeclarationAccessibility.Public)"); + } + + [Test] + public async Task Property_WithTargetTypedOptions_UsesMinimalOverload(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.Property(new("Name", TypeReference.Create<string>(), TypeDeclarationAccessibility.Public)); + } + } + """; + + var result = await ApplyCodeFixAsync(source, Options, cancellationToken); + + await Assert + .That(result.FixedSource) + .Contains("writer.Property(\"Name\", TypeReference.Create<string>(), TypeDeclarationAccessibility.Public)"); + } +} diff --git a/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PreferPragmaDisableCodeFixProviderTests.cs b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PreferPragmaDisableCodeFixProviderTests.cs new file mode 100644 index 0000000..d142a5c --- /dev/null +++ b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PreferPragmaDisableCodeFixProviderTests.cs @@ -0,0 +1,58 @@ +using Purview.SourceGeneratorFramework.Analyzers; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +public sealed class PreferPragmaDisableCodeFixProviderTests + : TUnitCodeFixTestBase<PreferPragmaDisableAnalyzer, PreferPragmaDisableCodeFixProvider> +{ + static readonly CodeFixTestOptions Options = new() + { + EquivalenceKey = PreferPragmaDisableCodeFixProvider.EquivalenceKey, + AdditionalAssemblyTypes = [typeof(CodeWriter), typeof(GenerationSettings)], + }; + + [Test] + public async Task Line_WithPragmaWarningDisable_UsesPragmaDisable(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.Line("#pragma warning disable CS1591 CS0618"); + } + } + """; + + var result = await ApplyCodeFixAsync(source, Options, cancellationToken); + + await Assert.That(result).HasDiagnostic(PreferPragmaDisableAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("writer.PragmaDisable(\"CS1591\", \"CS0618\")"); + } + + [Test] + public async Task Line_WithPragmaWarningRestore_DoesNotRegisterFix(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.Line("#pragma warning restore CS1591"); + } + } + """; + + await Assert + .That(async () => await ApplyCodeFixAsync(source, Options, cancellationToken)) + .Throws<InvalidOperationException>(); + } +} diff --git a/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PreferStructuredCodeWriterStatementCodeFixProviderTests.cs b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PreferStructuredCodeWriterStatementCodeFixProviderTests.cs new file mode 100644 index 0000000..0e406f7 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PreferStructuredCodeWriterStatementCodeFixProviderTests.cs @@ -0,0 +1,210 @@ +using Purview.SourceGeneratorFramework.Analyzers; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +public sealed class PreferStructuredCodeWriterStatementCodeFixProviderTests + : TUnitCodeFixTestBase< + PreferStructuredCodeWriterStatementAnalyzer, + PreferStructuredCodeWriterStatementCodeFixProvider + > +{ + static readonly CodeFixTestOptions Options = new() + { + EquivalenceKey = PreferStructuredCodeWriterStatementCodeFixProvider.EquivalenceKey, + AdditionalAssemblyTypes = [typeof(CodeWriter), typeof(GenerationSettings)], + }; + + [Test] + public async Task Line_WithReturnStatement_UsesReturn(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.Line("return value;"); + } + } + """; + + var result = await ApplyCodeFixAsync(source, Options, cancellationToken); + + await Assert.That(result).HasDiagnostic(PreferStructuredCodeWriterStatementAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("writer.Return(\"value\")"); + } + + [Test] + public async Task Line_WithMethodCall_UsesMethodCall(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.Line("Process(item);"); + } + } + """; + + var result = await ApplyCodeFixAsync(source, Options, cancellationToken); + + await Assert.That(result.FixedSource).Contains("writer.MethodCall(\"Process\", \"item\")"); + } + + [Test] + public async Task Line_WithReceiverMethodCall_UsesMethodCallOn(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.Line("variable.Process(item);"); + } + } + """; + + var result = await ApplyCodeFixAsync(source, Options, cancellationToken); + + await Assert.That(result.FixedSource).Contains("writer.MethodCallOn(\"variable\", \"Process\", \"item\")"); + } + + [Test] + public async Task Line_WithAwaitedReceiverMethodCall_UsesAwaitedMethodCallOn(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.Line("await service.LoadAsync(token);"); + } + } + """; + + var result = await ApplyCodeFixAsync(source, Options, cancellationToken); + + await Assert + .That(result.FixedSource) + .Contains("writer.AwaitedMethodCallOn(\"service\", \"LoadAsync\", \"token\")"); + } + + [Test] + public async Task Line_WithAssignment_UsesAssignment(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.Line("value = 42;"); + } + } + """; + + var result = await ApplyCodeFixAsync(source, Options, cancellationToken); + + await Assert.That(result.FixedSource).Contains("writer.Assignment(\"value\", \"42\")"); + } + + [Test] + public async Task Line_WithComment_UsesComment(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.Line("// generated note"); + } + } + """; + + var result = await ApplyCodeFixAsync(source, Options, cancellationToken); + + await Assert.That(result.FixedSource).Contains("writer.Comment(\"generated note\")"); + } + + [Test] + public async Task Line_WithUsingDirective_UsesUsing(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.Line("using System;"); + } + } + """; + + var result = await ApplyCodeFixAsync(source, Options, cancellationToken); + + await Assert.That(result.FixedSource).Contains("writer.Using(\"System\")"); + } + + [Test] + public async Task Line_WithXmlDocumentationComment_DoesNotRegisterFix(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.Line("/// summary"); + } + } + """; + + await Assert + .That(async () => await ApplyCodeFixAsync(source, Options, cancellationToken)) + .Throws<InvalidOperationException>(); + } + + [Test] + public async Task Line_WithBlockHeader_DoesNotRegisterFix(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.Line("if (enabled)"); + } + } + """; + + await Assert + .That(async () => await ApplyCodeFixAsync(source, Options, cancellationToken)) + .Throws<InvalidOperationException>(); + } +} diff --git a/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/LogAttributeDataTests.cs b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/LogAttributeDataTests.cs index 8061420..34a278a 100644 --- a/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/LogAttributeDataTests.cs +++ b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/LogAttributeDataTests.cs @@ -75,7 +75,7 @@ public async Task LogAttributeData_PositionalMessage_IsRead() // LogAttribute.Message has a private setter, so the message only ever arrives through the constructor. var attribute = AttributeDataFactory.FromUsage("[Log(\"hello\", EventId = 3)]", ExamplesOptions); - var model = Examples.LogAttributeData.FromAttributeData(attribute); + var model = LogAttributeData.FromAttributeData(attribute); await Assert.That(model.Message).IsEqualTo("hello"); await Assert.That(model.EventId).IsEqualTo(3); @@ -87,7 +87,7 @@ public async Task DebugAttributeData_PositionalMessage_FlowsIntoNestedModel() { var attribute = AttributeDataFactory.FromUsage("[Debug(\"hi\")]", ExamplesOptions); - var model = Examples.DebugAttributeData.FromAttributeData(attribute); + var model = DebugAttributeData.FromAttributeData(attribute); await Assert.That(model.Log.Message).IsEqualTo("hi"); await Assert.That(model.Level).IsEqualTo(LogLevel.Debug); diff --git a/src/tests/SourceGeneratorFramework.UnitTests/CodeQueryTests.cs b/src/tests/SourceGeneratorFramework.UnitTests/CodeQueryTests.cs index b449e9d..37c577c 100644 --- a/src/tests/SourceGeneratorFramework.UnitTests/CodeQueryTests.cs +++ b/src/tests/SourceGeneratorFramework.UnitTests/CodeQueryTests.cs @@ -325,15 +325,13 @@ public async Task GetAllAndCount_ReturnAllMatches() await Assert.That(query.GetAll<MethodDeclarationSyntax>().Length).IsEqualTo(3); await Assert.That(query.GetAll(ValueStartsWith()).Length).IsEqualTo(1); - static Func<MethodDeclarationSyntax, bool> ValueStartsWith() - { - return static method => method.Identifier.ValueText + static Func<MethodDeclarationSyntax, bool> ValueStartsWith() => + static method => method.Identifier.ValueText #if NET11_0_OR_GREATER .StartsWith('C', StringComparison.Ordinal); #else .StartsWith('C'); #endif - } } [Test] diff --git a/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs b/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs index 7ff5165..a577a32 100644 --- a/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs @@ -703,6 +703,125 @@ await Assert ); } + [Test] + public async Task Union_WithCaseTypes_WritesUnionDeclaration() + { + var writer = CodeWriter.CreateTestWriter(); + TypeDeclarationOptions declaration = new("Pet", TypeDeclarationAccessibility.Public) + { + UnionCaseTypes = [Type("Cat"), Type("Dog"), Type("Bird")], + }; + + writer.Union(declaration); + + await Assert.That(writer.ToString()).IsEqualTo("public partial union Pet(Cat, Dog, Bird);\n"); + } + + [Test] + public async Task Union_WithBody_WritesUnionBlock() + { + var writer = CodeWriter.CreateTestWriter(); + TypeDeclarationOptions declaration = new("OneOrMore", TypeDeclarationAccessibility.Public) + { + UnionCaseTypes = [Type("T"), Type("IEnumerable").Identity.MakeGeneric(Type("T"))], + GenericTypes = [new("T")], + Interfaces = [Type("ITest")], + }; + + using (writer.UnionScope(declaration)) + { + // Here to prevent IDE0055 + } + + await Assert + .That(writer.ToString()) + .IsEqualTo("public partial union OneOrMore<T>(T, IEnumerable<T>) : ITest\n" + "{\n" + "}\n"); + } + + [Test] + public async Task Union_WithoutCaseTypes_Throws() + { + var writer = CodeWriterFactory.ForTests(); + + await Assert.That(() => writer.Union(new TypeDeclarationOptions("Pet"))).Throws<ArgumentException>(); + } + + [Test] + public async Task Union_GivenBaseType_Throws() + { + var writer = CodeWriterFactory.ForTests(); + TypeDeclarationOptions declaration = new("Pet") { UnionCaseTypes = [Type("Cat")], BaseType = Type("Base") }; + + await Assert.That(() => writer.Union(declaration)).Throws<ArgumentException>(); + } + + [Test] + public async Task Union_GivenPrimaryConstructorParameters_Throws() + { + var writer = CodeWriterFactory.ForTests(); + TypeDeclarationOptions declaration = new("Pet") + { + UnionCaseTypes = [Type("Cat")], + PrimaryConstructorParameters = [new("cat", Type("Cat"))], + }; + + await Assert.That(() => writer.Union(declaration)).Throws<ArgumentException>(); + } + + [Test] + public async Task Union_GivenClosedModifier_Throws() + { + var writer = CodeWriterFactory.ForTests(); + TypeDeclarationOptions declaration = new("Pet") { UnionCaseTypes = [Type("Cat")], IsClosed = true }; + + await Assert.That(() => writer.Union(declaration)).Throws<ArgumentException>(); + } + + [Test] + public async Task ClosedClass_WritesClosedInsteadOfSealed() + { + var writer = CodeWriterFactory.ForTests(); + TypeDeclarationOptions declaration = new("JobStatus", TypeDeclarationAccessibility.Public) + { + Kind = TypeDeclarationKind.RecordClass, + IsClosed = true, + IsSealed = true, + }; + + writer.RecordClass(declaration); + + await Assert + .That(writer.ToString()) + .IsEqualTo(GeneratedAttributes() + "public closed partial record class JobStatus;\n"); + } + + [Test] + public async Task ClosedClass_GivenStruct_Throws() + { + var writer = CodeWriterFactory.ForTests(); + TypeDeclarationOptions declaration = new("Invalid") { IsClosed = true }; + + await Assert.That(() => writer.Struct(declaration)).Throws<ArgumentException>(); + } + + [Test] + public async Task ClosedClass_GivenStatic_Throws() + { + var writer = CodeWriterFactory.ForTests(); + TypeDeclarationOptions declaration = new("Invalid") { IsClosed = true, IsStatic = true }; + + await Assert.That(() => writer.Class(declaration)).Throws<ArgumentException>(); + } + + [Test] + public async Task ClosedClass_GivenAbstract_Throws() + { + var writer = CodeWriterFactory.ForTests(); + TypeDeclarationOptions declaration = new("Invalid") { IsClosed = true, IsAbstract = true }; + + await Assert.That(() => writer.Class(declaration)).Throws<ArgumentException>(); + } + [Test] public async Task Type_WithoutAccessibility_OmitsAccessibility() { diff --git a/src/tests/SourceGeneratorShared.UnitTests/Helpers/TypeHelpersTests.cs b/src/tests/SourceGeneratorShared.UnitTests/Helpers/TypeHelpersTests.cs index 0c643d0..0bbfdf3 100644 --- a/src/tests/SourceGeneratorShared.UnitTests/Helpers/TypeHelpersTests.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/Helpers/TypeHelpersTests.cs @@ -488,6 +488,40 @@ await Assert .IsEqualTo(GeneratedAttributes() + "public partial class Container<T>\n{\n}\n"); } + [Test] + public async Task CreatePartialTypeDeclarationOptions_GivenClosedType_PreservesClosedModifier( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace System.Runtime.CompilerServices + { + [System.AttributeUsage(System.AttributeTargets.Class)] + internal sealed class IsClosedTypeAttribute : System.Attribute { } + } + + [System.Runtime.CompilerServices.IsClosedType] + public abstract partial class Container + { + } + """; + var symbol = await GetTypeSymbolAsync(source, "Container", cancellationToken); + + // Act + var declaration = TypeHelpers.CreatePartialTypeDeclarationOptions(symbol); + var writer = CodeWriterFactory.ForTests(); + using (writer.TypeScope(declaration)) + { + // Intentionally empty. + } + + // Assert + await Assert.That(declaration.IsClosed).IsTrue(); + await Assert.That(declaration.IsAbstract).IsFalse(); + await Assert.That(writer.ToString()).Contains("public closed partial class Container"); + } + [Test] public async Task IsAccessibleAsPublicOrInternal_PublicType_ReturnsTrue(CancellationToken cancellationToken) {