diff --git a/.config/org-inventory.json b/.config/org-inventory.json new file mode 100644 index 0000000..73dfe6f --- /dev/null +++ b/.config/org-inventory.json @@ -0,0 +1,381 @@ +{ + "schemaVersion": 1, + "org": "purview-dev", + "branch": "main", + "fetchedAt": "2026-11-03T15:46:07Z", + "repositories": [ + { + "name": "architests", + "visibility": "public", + "archived": false, + "commitSha": "e51e9ea3f03fc6115a8631ede55fed63791c7aa8", + "pushedAt": "2026-11-02T23:13:25Z", + "docsDir": "docs", + "docsPages": 9, + "skills": [ + "purview-architests", + "purview-architests-contracts", + "purview-architests-extending", + "purview-architests-migration", + "purview-architests-policy-authoring", + "purview-architests-type-rules" + ], + "agentGuidance": [ + "AGENTS.md" + ], + "concerns": [ + "architecture policy", + "dependency and naming rules" + ] + }, + { + "name": "aspire-resourcekit", + "visibility": "public", + "archived": false, + "commitSha": "346392d7b78289b2685a1ef1e71ebc9f08daa902", + "pushedAt": "2026-11-02T23:13:30Z", + "docsDir": "docs/wiki", + "docsPages": 20, + "skills": [ + "purview-resourcekit-authoring", + "purview-resourcekit-diagnostics", + "purview-resourcekit-migration", + "purview-resourcekit-options", + "purview-resourcekit-testing" + ], + "agentGuidance": [ + "AGENTS.md", + ".github/copilot-instructions.md" + ], + "concerns": [ + "Aspire hosting resources" + ] + }, + { + "name": "aspirec4", + "visibility": "public", + "archived": false, + "commitSha": "2c955081e4023ad1fca2c6012946dc00d4078ab9", + "pushedAt": "2026-11-02T23:13:35Z", + "docsDir": "docs/wiki", + "docsPages": 15, + "skills": [ + "purview-aspirec4-configuration", + "purview-aspirec4-entityframework", + "purview-aspirec4-quickstart", + "purview-aspirec4-resources", + "purview-aspirec4-validation" + ], + "agentGuidance": [ + "AGENTS.md" + ], + "concerns": [ + "architecture diagrams from source" + ] + }, + { + "name": "build", + "visibility": "public", + "archived": false, + "commitSha": "d00b005dd565f59f5b8641b2b80377d4e6e52c32", + "pushedAt": "2026-11-02T23:03:07Z", + "docsDir": "docs/wiki", + "docsPages": 14, + "skills": [ + "purview-build-adoption", + "purview-build-configuration", + "purview-build-pack-validation", + "purview-build-pipeline", + "purview-build-release-eligibility" + ], + "agentGuidance": [ + "AGENTS.md" + ], + "concerns": [ + "pipeline", + "release" + ] + }, + { + "name": "build-sdk", + "visibility": "public", + "archived": false, + "commitSha": "052cf6b99577a2d2fe41cdacdb246b9e82171e37", + "pushedAt": "2026-11-02T23:27:34Z", + "docsDir": "docs/wiki", + "docsPages": 19, + "skills": [ + "purview-buildsdk-adoption", + "purview-buildsdk-configuration", + "purview-buildsdk-project-detection", + "purview-buildsdk-project-layout", + "purview-buildsdk-repository-review", + "purview-buildsdk-skill-installation", + "purview-buildsdk-skill-tests", + "purview-buildsdk-testing" + ], + "agentGuidance": [ + "AGENTS.md", + ".github/copilot-instructions.md" + ], + "concerns": [ + "repository shape", + "analyzers and style", + "MSBuild defaults", + "skill authoring" + ] + }, + { + "name": "containers", + "visibility": "public", + "archived": false, + "commitSha": "b94d92afd52af1d509bc481b20c83fb9bb57badd", + "pushedAt": "2026-11-02T23:27:39Z", + "docsDir": "docs/wiki", + "docsPages": 19, + "skills": [ + "purview-containers-capabilities", + "purview-containers-consumer-requirements", + "purview-containers-lifecycle", + "purview-containers-quickstart", + "purview-containers-testing" + ], + "agentGuidance": [ + "AGENTS.md", + ".github/copilot-instructions.md" + ], + "concerns": [ + "local dependencies in tests" + ] + }, + { + "name": "engineering-playbook", + "visibility": "public", + "archived": false, + "commitSha": "ccf5b3d14616c1cbc904167439635909ed21d14c", + "pushedAt": "2026-11-03T15:40:26Z", + "docsDir": "docs/wiki", + "docsPages": 19, + "skills": [ + "purview-architecture", + "purview-architecture-testing", + "purview-engineering-principles", + "purview-persistence", + "purview-telemetry", + "purview-testing-conventions" + ], + "agentGuidance": [ + "AGENTS.md", + ".github/copilot-instructions.md" + ], + "concerns": [ + "the conventions themselves" + ] + }, + { + "name": "event-sourcing", + "visibility": "public", + "archived": false, + "commitSha": "607ff712d5cdcadb9afabc4f6293402b306474b2", + "pushedAt": "2026-11-02T23:13:42Z", + "docsDir": "docs/wiki", + "docsPages": 23, + "skills": [], + "agentGuidance": [ + "AGENTS.md", + ".github/copilot-instructions.md" + ], + "concerns": [ + "event streams", + "event storage" + ] + }, + { + "name": "openapi-xmlcomments", + "visibility": "public", + "archived": false, + "commitSha": "2934a32647185c9e7af37f849e88aa68e910f90e", + "pushedAt": "2026-11-03T13:08:21Z", + "docsDir": "docs/wiki", + "docsPages": 10, + "skills": [ + "purview-openapi-xmlcomments-duplicates" + ], + "agentGuidance": [ + "AGENTS.md", + ".github/copilot-instructions.md" + ], + "concerns": [ + "OpenAPI from XML comments" + ] + }, + { + "name": "pm-skills", + "visibility": "public", + "archived": false, + "commitSha": "8460e52f4a36fd6cb59e6169e047448082b7b6ad", + "pushedAt": "2026-11-01T23:28:22Z", + "docsDir": null, + "docsPages": 0, + "skills": [ + "pm-checkpoint", + "pm-discover", + "pm-help", + "pm-init", + "pm-item", + "pm-next", + "pm-park", + "pm-purview", + "pm-resume", + "pm-review", + "pm-setup", + "pm-shape", + "pm-status", + "pm-strategy" + ], + "agentGuidance": [ + "AGENTS.md", + ".github/copilot-instructions.md" + ], + "concerns": [ + "skill-set design" + ] + }, + { + "name": "purview-dev.github.io", + "visibility": "public", + "archived": false, + "commitSha": "46fdb0e7a3155059aba132221737c1f6868542a6", + "pushedAt": "2026-11-03T15:28:00Z", + "docsDir": "docs", + "docsPages": 2, + "skills": [], + "agentGuidance": [ + "AGENTS.md", + ".github/copilot-instructions.md" + ], + "concerns": [ + "the public project catalogue" + ] + }, + { + "name": "results", + "visibility": "public", + "archived": false, + "commitSha": "31812cc154c6e43a775b9a588f5154cf5280737a", + "pushedAt": "2026-11-03T00:54:02Z", + "docsDir": "docs/wiki", + "docsPages": 18, + "skills": [ + "purview-results-core", + "purview-results-http-mapping", + "purview-results-union-errors", + "purview-results-zodsharp-problems", + "purview-results-zodsharp-validation" + ], + "agentGuidance": [ + "AGENTS.md", + ".github/copilot-instructions.md" + ], + "concerns": [ + "operation outcomes" + ] + }, + { + "name": "sourcegenerator-framework", + "visibility": "public", + "archived": false, + "commitSha": "744343dec62579478bed4d6039edaea21fec18ce", + "pushedAt": "2026-11-03T13:40:08Z", + "docsDir": "docs/wiki", + "docsPages": 17, + "skills": [ + "purview-sourcegenerator-attribute-data-models", + "purview-sourcegenerator-codewriter", + "purview-sourcegenerator-diagnostics", + "purview-sourcegenerator-packaging", + "purview-sourcegenerator-pipeline", + "purview-sourcegenerator-testing", + "purview-sourcegenerator-tunit-tests" + ], + "agentGuidance": [ + "AGENTS.md", + ".github/copilot-instructions.md" + ], + "concerns": [ + "source generator authoring" + ] + }, + { + "name": "telemetry-sourcegenerator", + "visibility": "public", + "archived": false, + "commitSha": "032933fb39bb5302ec7faceed72803d2ebce56f8", + "pushedAt": "2026-11-03T15:21:28Z", + "docsDir": "docs/wiki", + "docsPages": 25, + "skills": [ + "purview-telemetry-design", + "purview-telemetry-diagnostics", + "purview-telemetry-migration", + "purview-telemetry-multi-targeting", + "purview-telemetry-quickstart" + ], + "agentGuidance": [ + "AGENTS.md", + ".github/copilot-instructions.md" + ], + "concerns": [ + "logging", + "tracing", + "metrics" + ] + }, + { + "name": "value-objects", + "visibility": "public", + "archived": false, + "commitSha": "b8d604926fb9d800927c5e24ac0eb6e4216fd655", + "pushedAt": "2026-11-03T00:15:52Z", + "docsDir": "docs", + "docsPages": 6, + "skills": [ + "purview-value-objects", + "purview-value-objects-diagnostics", + "purview-value-objects-efcore", + "purview-value-objects-zodsharp" + ], + "agentGuidance": [ + "AGENTS.md" + ], + "concerns": [ + "validated identity types" + ] + }, + { + "name": "zodsharp", + "visibility": "public", + "archived": false, + "commitSha": "d0dbc74417d4445226a58ef96464d4d3519bd3c4", + "pushedAt": "2026-11-02T23:19:42Z", + "docsDir": "docs/wiki", + "docsPages": 35, + "skills": [ + "purview-zodsharp-aspnetcore", + "purview-zodsharp-blazor", + "purview-zodsharp-core", + "purview-zodsharp-custom-rules", + "purview-zodsharp-diagnostics", + "purview-zodsharp-json", + "purview-zodsharp-source-generator" + ], + "agentGuidance": [ + "AGENTS.md", + ".github/copilot-instructions.md" + ], + "concerns": [ + "TypeScript contracts from C#" + ] + } + ] +} diff --git a/.gitignore b/.gitignore index d05262a..82f2a34 100644 --- a/.gitignore +++ b/.gitignore @@ -661,3 +661,8 @@ sketch !build/ .tools/ + +# Purview addition, not in the SDK's template: the skills CLI's universal install location. An +# install is reproducible from skills-lock.json, and it must not appear as an untracked change in +# somebody's working tree. See docs/wiki/Agent-Enablement.md. +.agents/ diff --git a/AGENTS.md b/AGENTS.md index 90e4e2b..c1c66f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,13 +29,14 @@ implementation whose only job is to prove the conventions compile, run, and fail | `src/tests/Domain.UnitTests` | Value-object behaviour; the value-object exception to `TestBuilder` | | `src/tests/Application.UnitTests` | The service, against a TUnit.Mocks substitute | | `src/tests/Architecture.UnitTests` | Assembly dependency-direction rules and the architecture policy; a wrong `ProjectReference` fails the build | -| `src/tests/Repository.UnitTests` | The repository itself: skill conformance and documentation checks | +| `src/tests/Repository.UnitTests` | The repository itself: skill conformance, documentation checks, the organisation snapshot, and the harvest script run as a subprocess | | `src/tests/SharedTestingInfra` | `TestBuilder`; referenced automatically by every test project | | `skills/` | Authored agent skills, installable with `npx skills add purview-dev/engineering-playbook` | | `templates/` | Ready-to-copy starter files for a repository adopting the playbook | | `src/Directory.Build.props` | `NamespacePrefix`, the SDK import, and `NamespaceRemoveSuffix` | | `.config/purview-build.json` | Pipeline configuration: solution, test discovery, filter | | `.config/architecture-policy.json` | The dependency and naming policy the architecture tests read | +| `.config/org-inventory.json` | Generated snapshot of every public repository at `main`; `just org-inventory` reports the drift | | `Justfile` | Supported local commands | | `package.json` | Authoritative version; the release reads it and nothing else | @@ -87,6 +88,7 @@ build-time `.agents/` mirror outright. The skills CLI clones the git repository, | `skills/purview-testing-conventions/` | TUnit, AAA, `TestBuilder`, substitutes, tiers | | `skills/purview-persistence/` | storage approach, EF Core abstraction, GUID key strategy | | `skills/purview-telemetry/` | logging, tracing, metrics, and when a project is "too small" for them | +| `skills/purview-org-harvest/` | which repository owns which convention, and how it is read at `main` before anything is adopted | Every skill carries the `purview-` prefix: skills install into one flat directory per agent, shared with every skill a developer has installed from anywhere, so an unprefixed name collides and the loser is @@ -128,6 +130,8 @@ just build # dotnet build (Debug) just test # dotnet test just lint-check # CSharpier check just lint-fix # CSharpier format +just org-inventory # drift between the organisation and .config/org-inventory.json +just org-inventory-write # regenerate the snapshot; commit the diff with the harvest just pipeline-pr # restore, build, lint, test through the shared Purview.Build tool just scrub # reset bin/obj, forced restore, build-server shutdown ``` @@ -140,10 +144,18 @@ Commit messages follow Conventional Commits, enforced by the `commit-msg` hook. Before handing work back: +- **Never bypass a gate.** No `--no-verify` on a commit or a push, no `LEFTHOOK_EXCLUDE`/`SKIP`, no + `continue-on-error` or `|| true` on a check, no force-push over a protected ref. If a hook is wrong or + too slow, change the hook in its own reviewed change. "CI will run the same thing anyway" is a claim + about a different run, made by the party being checked — and a bypass leaves no trace, which is why it + is never a judgement call. See [Verification Model](docs/wiki/Verification-Model.md). - Confirm the requested behaviour and scope are satisfied, and that only intended files changed. - If a rule was added or changed: the page states the failure it prevents, says whether it is enforced or a review obligation, has an "at your scale" section, is in `_Sidebar.md` and the `Home.md` table, and is demonstrated in `src/` — or the page says why it is not. +- If a page names a public repository: confirm the claim is true at that repository's `main` now, not at a + local clone, and that the name is still the current one. `just org-inventory` reports the drift; a renamed + repository keeps resolving through a redirect, so a stale citation looks healthy until it is compared. - If `src/` changed: the build is still warning-free, the architecture rules still pass, and the pages that describe the changed code are still accurate. - Run the checks in proportion to the risk, and state exactly what ran. If a check was skipped or blocked, diff --git a/Directory.Packages.props b/Directory.Packages.props index 0931036..e748001 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,12 +23,12 @@ 10.10.0 - 5.0.5 + 5.1.0 - 1.1.0 + 1.2.0 1.76.0 @@ -48,7 +48,7 @@ - + diff --git a/Justfile b/Justfile index accea12..60b9353 100644 --- a/Justfile +++ b/Justfile @@ -99,6 +99,22 @@ lint-fix: version: bun -e "console.log('Current Version: {{ GREEN }}' + require('./package.json').version + '{{ NORMAL }}')" +# ----------------------------------------------------------------------------- +# Organisation +# ----------------------------------------------------------------------------- + +# Prints what changed in the organisation since the committed snapshot was taken +[group('Organisation')] +org-inventory *args: + echo "Reading {{ BLUE }}purview-dev{{ NORMAL }} at main..." + node skills/purview-org-harvest/scripts/inventory.mjs {{ args }} + +# Regenerates .config/org-inventory.json from the organisation; commit the diff with the harvest +[group('Organisation')] +org-inventory-write *args: + echo "Rewriting {{ BLUE }}.config/org-inventory.json{{ NORMAL }}..." + node skills/purview-org-harvest/scripts/inventory.mjs --write {{ args }} + # ----------------------------------------------------------------------------- # System / Shell # ----------------------------------------------------------------------------- diff --git a/docs/wiki/Agent-Guidance-Skills.md b/docs/wiki/Agent-Guidance-Skills.md index 7373d26..6d47ced 100644 --- a/docs/wiki/Agent-Guidance-Skills.md +++ b/docs/wiki/Agent-Guidance-Skills.md @@ -13,6 +13,7 @@ library, and they are the only place that guidance is written: | `purview-testing-conventions` | How are tests written and fed — framework, structure, naming, and `TestBuilder`? | | `purview-telemetry` | What instrumentation does a project of this size get? (Answer: always some.) | | `purview-persistence` | How is data stored, and — only when it applies — how are GUID keys generated? | +| `purview-org-harvest` | Which repository owns which convention, and how is it read at `main` before anything is adopted? | Two obligations fall out of that, and both are enforced only by review: diff --git a/docs/wiki/Cross-Repository-Harvest.md b/docs/wiki/Cross-Repository-Harvest.md new file mode 100644 index 0000000..d0e18d2 --- /dev/null +++ b/docs/wiki/Cross-Repository-Harvest.md @@ -0,0 +1,136 @@ +# Cross-Repository Harvest + +## The rule + +Every convention in this playbook was harvested from a **named, public `purview-dev` repository, read +at `main`**. The remote is the authority; a local clone is a cache, and it may be stale, renamed or +missing. + +`.config/org-inventory.json` records what was read: the organisation, the branch, when it was fetched, +and a commit SHA per repository. It is generated, never hand-edited. + +| Field | Holds | +| -- | -- | +| `org`, `branch` | `purview-dev`, `main` — the organisation and the branch a convention is read at | +| `fetchedAt` | When the snapshot was taken. One date for the whole file, so staleness is one comparison | +| `repositories[].commitSha` | The commit the claims about that repository were true at | +| `repositories[].visibility`, `archived` | Public and unarchived, or the entry does not belong here | +| `repositories[].docsDir`, `docsPages` | Where the documentation lives, and how much of it there is | +| `repositories[].skills` | The skill folders it authors | +| `repositories[].agentGuidance` | The agent-facing files it publishes | +| `repositories[].concerns` | The concerns it is best-in-class for | + +**Private repositories are inputs, never evidence.** They may inform a maintainer's judgement; they +are never cited, never named and never recorded. Three mechanisms hold that line, and they fail in +different places: + +- The writer **denies by default**: only a repository whose visibility is exactly `PUBLIC` and whose + `archived` flag is exactly `false` reaches the file, so a visibility value nobody anticipated fails + closed rather than falling through. +- The writer **refuses to persist** a snapshot that records anything else — including an entry a hand + edit left behind, which a merge would otherwise carry forward. +- No file this repository publishes may name a repository the snapshot does not contain, so a private + repository cannot be cited in a page, a skill, a workflow or a config and still pass. + +## Why + +**A local folder listing is not an inventory.** Two clones in a typical working directory here are +`eventsourcing` and `sourcegeneratorframework`; the repositories are `event-sourcing` and +`sourcegenerator-framework`. Four public repositories have no clone at all. An agent that globs the +parent directory, or a person reading it, gets a list that is short and partly misnamed — and every +entry in it looks authoritative, because it is a directory with code in it. This is the failure the +whole convention exists to prevent: the evidence was never read, only the neighbourhood. + +**A renamed repository keeps resolving, so nothing catches it.** `Home.md` cited the catalogue +repository under its former name. That repository is now `purview-dev.github.io`. GitHub redirects, +`gh api` follows the redirect and answers 200 with the new name, and a reader who clicks the link +lands on a page that loads. The playbook was describing a name that no longer existed, and the only +symptom was one extra hop nobody could see. This page's repository-name test was written to catch +exactly that, and the correction to `Home.md` ships with it — a test with nothing to fail on is a +test nobody believes. + +**A claim three pages agree on has not been checked.** `AGENTS.md`, `Agent-Enablement.md` and +`Repository-Shape.md` all stated that `.agents/skills/` is gitignored. `git check-ignore +.agents/skills/x/SKILL.md` exits 1, and the SDK's own `.gitignore` template carries no such line: the +rule was stated in three places and existed in none. Repetition reads as verification, which is why a +harvest records a SHA rather than an impression. The rule is now in `.gitignore`, so the three pages +are true rather than merely consistent. + +**An index maintained by hand under-counts silently.** The public catalogue in +`purview-dev.github.io` carries fifteen project files, one of which is archived, against sixteen +public live repositories: `openapi-xmlcomments` has no entry. A catalogue is a good reading order and +a bad membership list, because it omits by never being edited. `gh repo list` decides membership. + +**A convention copied from the wrong repository is worse than none.** `build-sdk` owns what the +tooling does; this playbook owns why a repository is shaped that way. Reading either one for the +other's question produces an answer with a citation that does not support it. + +## Deviations the first harvest found + +The [Documentation Convention](Documentation-Convention.md) says a wiki lives in `docs/wiki`. Of the +sixteen public repositories, twelve do. `architests`, `value-objects` and `purview-dev.github.io` +publish from `docs`, and `pm-skills` publishes no documentation folder at all, documenting itself +through its fourteen skills. + +That is not sixteen repositories getting a rule wrong; it is one repository's shape written down as if +it were universal. What the rule actually requires is that the documentation lives with the code and +that the repository owns its site configuration — the folder name is the part that varies. The +convention page now says so, and names each deviation rather than pretending the rule was followed. + +## How it is enforced + +| Claim | Enforced by | +| -- | -- | +| The snapshot records public, unarchived repositories only | `OrgInventoryTests`; `inventory.mjs` refuses to write anything else | +| No published file names a repository the snapshot does not contain | `OrgInventoryTests` | +| Every skill declares the repository that publishes it | `OrgInventoryTests` | +| The script drops a private repository, refuses a private entry, and reads a BOM'd file | `InventoryScriptTests` | +| A page is reachable from the navigation, and its fences declare a language | `DocumentationTests` | +| The snapshot is current, and the analysis actually read `main` | **Review obligation** | + +`InventoryScriptTests` runs the script itself as a subprocess — `node`, the script, arguments, no +shell — so the same assertions execute on Windows and on Linux, which is the point: the tooling is one +Node script rather than a PowerShell one and a Bash one that disagree. It replays a recorded membership +with `--from` and `--details` instead of faking `gh` on `PATH`; a shim was tried first and rejected, +because a non-shell spawn will not resolve a `.cmd` and the tests then reached the real `gh` and passed +against sixteen live repositories. + +The second row is the one that catches a private repository by name, and it is deliberately blunt: it +reads every `.md`, `.cs`, `.json`, `.yml`, `.props` and `.mjs` file in the repository rather than the +wiki alone, because a skill is installed into somebody else's agent and a workflow file is a public +build log. A name that reaches either surface without passing through the script fails the build. + +What no offline test can know is whether an entry's recorded visibility is a lie, because the truth +lives in the organisation rather than in the file. The compensating control is the drift report: +membership is re-read from `gh` rather than trusted from the file, so an entry that *stops* being +public prints `in the snapshot but no longer a public repository` the next time `just org-inventory` +runs. + +The last row is the honest one. The snapshot is a claim about a moment, and a moment cannot be +enforced by a test that runs later: failing the build on an old `fetchedAt` fails whoever pushed next, +on a Tuesday, for a file they did not touch. So `just org-inventory` reports the drift and says when +the snapshot passed thirty days, and a reviewer decides. + +The snapshot also trails this repository by one merge, by construction. It records the remote's SHA, +and a change is not on `main` until it is merged, so this repository's own row is always one commit +behind its tip. Regenerating the snapshot is part of merging a harvest, not part of making one. + +## At your scale + +- **One repository.** Keep a provenance list in `AGENTS.md`: one line per rule, naming the repository + and the path it came from. No snapshot, no script — you have one copy of the truth and a name to + check it against. +- **A few repositories.** A hand-written list is still enough, but date it. An undated list is read as + current, which is the same failure with fewer steps. +- **A platform.** Commit the snapshot, generate it with a script, and assert in a test that the + repositories your documentation names are in it. That test is cheap, offline, and the only rule + here that can be enforced without a network call — which is why it is the one worth automating + first. + +## See also + +- [Documentation Convention](Documentation-Convention.md) — where a page lives, and why ownership is + the rule rather than the folder name +- [Agent Skills Distribution](Agent-Skills-Distribution.md) — how a skill reaches a consumer +- [Engineering Principles](Engineering-Principles.md) — the rules a harvest most often finds behind +- [Adopting This at Your Scale](Adopting-This-At-Your-Scale.md) diff --git a/docs/wiki/Documentation-Convention.md b/docs/wiki/Documentation-Convention.md index a70c74c..039578e 100644 --- a/docs/wiki/Documentation-Convention.md +++ b/docs/wiki/Documentation-Convention.md @@ -15,6 +15,14 @@ its navigation and its `edit_uri`. | `README.md` | The repository's front door on GitHub. Points into the wiki; does not duplicate it. | | `AGENTS.md` | The agent's front door. Points into the wiki; does not duplicate it. | +**Scope.** This is the layout of a repository that publishes a site, and `docs/wiki` is the folder +this playbook publishes from. Of the sixteen public repositories in the organisation, twelve use +`docs/wiki`; `architests`, `value-objects` and `purview-dev.github.io` publish from `docs`, and +`pm-skills` publishes no documentation folder at all, documenting itself through its skills. What the +rule requires is the part those repositories share: the documentation lives with the code it +describes, and the repository owns its `mkdocs.yml`. The folder name is the part that varies. +[Cross-Repository Harvest](Cross-Repository-Harvest.md) records the deviations by name. + ## Why **Ownership.** Documentation that lives outside the repository it describes is updated outside it. A page @@ -106,7 +114,11 @@ else, state it here and say why. **Say which repository the evidence came from.** Every rule in this playbook came from a real repository and a real failure. Naming the repository turns a claim into something a reader can check against running code, -and it is the fastest way for a reader to decide whether the rule applies to them. +and it is the fastest way for a reader to decide whether the rule applies to them. Then check the name is +still the current one: a renamed repository keeps resolving through a redirect, so a stale citation looks +healthy until it is compared with the organisation. [Cross-Repository +Harvest](Cross-Repository-Harvest.md) is that comparison, and the snapshot it records. + ## Writing style diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 03c3591..6c6438e 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -44,6 +44,7 @@ rule in full. | [Agent Skills Distribution](Agent-Skills-Distribution.md) | How a skill is published once and reaches both a package's consumers and an agent's own directories. | | [Agent Guidance Skills](Agent-Guidance-Skills.md) | The shared architecture, telemetry and persistence guidance, and the context an agent must establish first. | | [Documentation Convention](Documentation-Convention.md) | How the wiki is written, structured and published. | +| [Cross-Repository Harvest](Cross-Repository-Harvest.md) | Which repository owns which convention, how it is read at `main`, and what the snapshot records. | | [Worked Examples](Worked-Examples.md) | The reference implementation in this repository, walked end to end. | | [Adopting This at Your Scale](Adopting-This-At-Your-Scale.md) | Which parts apply to a small, medium or large repository. | | [Contributing](Contributing.md) | How to change the playbook itself. | @@ -64,5 +65,7 @@ Every rule here is drawn from a public Purview repository — chiefly [containers](https://github.com/purview-dev/containers) (a multi-package library with two backends), [value-objects](https://github.com/purview-dev/value-objects), [results](https://github.com/purview-dev/results) and [event-sourcing](https://github.com/purview-dev/event-sourcing) (data exchange at a boundary), and -[purview-dev](https://github.com/purview-dev/purview-dev) (documentation as a published surface). Each -page names the repositories it was derived from, so a claim can always be checked against running code. +[purview-dev.github.io](https://github.com/purview-dev/purview-dev.github.io) (the public catalogue, +and documentation as a published surface). Each page names the repositories it was derived from, so a +claim can always be checked against running code; [Cross-Repository +Harvest](Cross-Repository-Harvest.md) is how that claim stays true. diff --git a/docs/wiki/Verification-Model.md b/docs/wiki/Verification-Model.md index 4a0f8ea..afc6ad9 100644 --- a/docs/wiki/Verification-Model.md +++ b/docs/wiki/Verification-Model.md @@ -156,6 +156,37 @@ Three things are visible in that snippet, and all three are deliberate: the repo caller; the pack switches match `.config/purview-build.json`; and the filter is the same string as the one in the configuration, so the two cannot drift apart silently. +## A gate is not bypassed + +A check that runs before a merge — a hook, a required status check, a lint step in the pipeline — is +never skipped by the person or agent being checked. Not because the gate looks redundant, not because +it is slow, not because "CI will run the same thing anyway". + +The concrete forms of the violation, which is why they are worth listing: + +| Command | What it skips | +| -- | -- | +| `git commit --no-verify` | The pre-commit hooks | +| `git push --no-verify`, `git push -n` | The pre-push chain | +| `LEFTHOOK_EXCLUDE=…`, `SKIP=…`, `HUSKY=0` | The named hooks, silently | +| `--force` / `--force-with-lease` onto a protected ref | The history the gate ran against | +| `continue-on-error`, `if: always()`, trailing `|| true` on a gate step | The gate's ability to fail | +| Deleting or renaming the failing step | Any evidence that it ever ran | + +**Why the list matters: a bypass leaves no trace.** A failing check is visible on the pull request +forever; a skipped one is indistinguishable from a passing one afterwards. So the cost is not the one +run — it is that the next person has no way to tell the gate was ever honoured. + +`purview-dev.github.io` is where this was earned. A `pre-push` hook ran `just validate`; the chain +hung in its network-dependent data sync, and the push was retried with `--no-verify`. The full chain +had passed minutes earlier and CI passed afterwards, so nothing shipped broken — and that is precisely +the trap: "it passed elsewhere" is a claim about a different run, made by the party with the incentive +to make it. The exemption was invisible, and it would have been invisible again next time. + +**If the gate is wrong or too slow, the gate is the thing to change**, in its own reviewed change, +with the reason attached. A gate that is routinely bypassed is not a gate — it is a comment that costs +ten minutes. + ## How it is enforced | Rule | Enforced by | @@ -163,6 +194,7 @@ in the configuration, so the two cannot drift apart silently. | The fast tier runs on every pull request | The caller workflow, and branch protection that makes it required | | The tier is derived from the project name | The build (SDK category emission) | | The repository's configuration is valid | The pipeline, which validates the JSON against the schema | +| A gate is never bypassed | **Review obligation** — and a required status check as the backstop, because a hook cannot enforce its own non-bypass | | The slow tier is run before a release | Review — and, where it matters, a release gate | ## At your scale @@ -173,6 +205,15 @@ in the configuration, so the two cannot drift apart silently. and never a pass. - **A package.** Turn on `run-pack` and `validate-pack`, and treat pack validation as the gate that stops a package shipping without the assets it documents. +- **One repository, no hooks.** Nothing to bypass yet. The trap to avoid is the made-up gate: a step that + runs the checks and then ignores the result (`|| true`, `continue-on-error`) is worse than no step, + because it looks like evidence. +- **A hook that is too slow.** Fix the hook — narrow what it runs, or move the slow tier behind a + required check — rather than passing `--no-verify`. The exemption you take once is the one the next + person takes by default. +- **A platform.** Make the gate a required status check as well as a hook, so the hook is a convenience + and the check is the guarantee. Keep an audit trail of who merged with a check missing, because that + is the only way a bypass is ever visible. - **Several repositories.** Keep the pipeline shared and let each repository own only its caller workflow and its configuration. That is the arrangement that makes a fix in one place a fix everywhere. diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index f9fe831..e664445 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -11,6 +11,7 @@ - [Agent Skills Distribution](Agent-Skills-Distribution.md) - [Agent Guidance Skills](Agent-Guidance-Skills.md) - [Documentation Convention](Documentation-Convention.md) +- [Cross-Repository Harvest](Cross-Repository-Harvest.md) - [Worked Examples](Worked-Examples.md) - [Adopting This at Your Scale](Adopting-This-At-Your-Scale.md) - [Contributing](Contributing.md) diff --git a/global.json b/global.json index 8b388e3..054e74d 100644 --- a/global.json +++ b/global.json @@ -1,11 +1,11 @@ { "sdk": { - "version": "11.0.100-rc.2.26504.105", + "version": "11.0.100-rc.3.26522.148", "rollForward": "latestMajor", "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.1.0" + "Purview.BuildSdk": "1.2.0" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/skills/purview-architecture-testing/SKILL.md b/skills/purview-architecture-testing/SKILL.md index 0619225..208b5e2 100644 --- a/skills/purview-architecture-testing/SKILL.md +++ b/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 diff --git a/skills/purview-architecture/SKILL.md b/skills/purview-architecture/SKILL.md index c04f3d6..8e76628 100644 --- a/skills/purview-architecture/SKILL.md +++ b/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 diff --git a/skills/purview-org-harvest/SKILL.md b/skills/purview-org-harvest/SKILL.md new file mode 100644 index 0000000..12652c3 --- /dev/null +++ b/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 + +Sixteen 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 + +Sixteen 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//git/trees/main?recursive=1`, for all sixteen | 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//contents/`, **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/skills/purview-org-harvest/references/best-in-class.md b/skills/purview-org-harvest/references/best-in-class.md new file mode 100644 index 0000000..48a579b --- /dev/null +++ b/skills/purview-org-harvest/references/best-in-class.md @@ -0,0 +1,70 @@ +# 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//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`, no `mkdocs.yml`** | +| Aspire hosting resources | `aspire-resourcekit` | `docs/wiki`, `skills/purview-resourcekit-*` | +| Architecture diagrams generated from source | `aspirec4` | `docs/wiki`, `skills/purview-aspirec4-*` | +| 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`. + +## Reading a repository you have not cloned + +Tier 1 first — the shape, for everything: + +```bash +gh api repos/purview-dev//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//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/skills/purview-org-harvest/references/harvest-commands.md b/skills/purview-org-harvest/references/harvest-commands.md new file mode 100644 index 0000000..76f6f0a --- /dev/null +++ b/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//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//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//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 fifteen. + +## 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/skills/purview-org-harvest/scripts/inventory.mjs b/skills/purview-org-harvest/scripts/inventory.mjs new file mode 100644 index 0000000..76fcad1 --- /dev/null +++ b/skills/purview-org-harvest/scripts/inventory.mjs @@ -0,0 +1,465 @@ +#!/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 # 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"]], + ["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 ."); + } +} + +/** 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 narrow to one repository; merges when writing + node inventory.mjs --out write somewhere other than .config/org-inventory.json + node inventory.mjs --from replay a recorded "gh repo list --json" response instead of + asking the organisation + node inventory.mjs --details 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/skills/purview-persistence/SKILL.md b/skills/purview-persistence/SKILL.md index 4667fa2..fabf957 100644 --- a/skills/purview-persistence/SKILL.md +++ b/skills/purview-persistence/SKILL.md @@ -9,6 +9,7 @@ license: MIT metadata: author: Purview-Dev version: "1.0" + repository: purview-dev/engineering-playbook --- # Purview persistence diff --git a/skills/purview-telemetry/SKILL.md b/skills/purview-telemetry/SKILL.md index 6d83173..d2f42d7 100644 --- a/skills/purview-telemetry/SKILL.md +++ b/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/src/tests/Repository.UnitTests/DocumentationTests.cs b/src/tests/Repository.UnitTests/DocumentationTests.cs index 832bde4..8900809 100644 --- a/src/tests/Repository.UnitTests/DocumentationTests.cs +++ b/src/tests/Repository.UnitTests/DocumentationTests.cs @@ -21,15 +21,14 @@ public sealed partial class DocumentationTests [GeneratedRegex(@"\]\((?[^)]+)\)")] private static partial Regex LinkPattern(); - static readonly string RepositoryRoot = FindRepositoryRoot(); - - static string WikiDirectory => Path.Combine(RepositoryRoot, "docs", "wiki"); - [Test] public async Task Pages_GivenTheSidebar_AreAllReachable(CancellationToken cancellationToken) { // Arrange - var sidebar = await File.ReadAllTextAsync(Path.Combine(WikiDirectory, "_Sidebar.md"), cancellationToken); + var sidebar = await File.ReadAllTextAsync( + Path.Combine(RepositoryLayout.WikiDirectory, "_Sidebar.md"), + cancellationToken + ); // Act var missing = ContentPages() @@ -47,7 +46,10 @@ await Assert public async Task Pages_GivenTheHomeTable_AreAllDiscoverable(CancellationToken cancellationToken) { // Arrange - var home = await File.ReadAllTextAsync(Path.Combine(WikiDirectory, "Home.md"), cancellationToken); + var home = await File.ReadAllTextAsync( + Path.Combine(RepositoryLayout.WikiDirectory, "Home.md"), + cancellationToken + ); // Act // Home is the hub itself, so it is the one page the hub's table does not list. @@ -71,12 +73,15 @@ public async Task Navigation_GivenItsLinks_AllResolve(CancellationToken cancella // Act foreach (var file in new[] { "_Sidebar.md", "Home.md" }) { - var lines = await File.ReadAllLinesAsync(Path.Combine(WikiDirectory, file), cancellationToken); + var lines = await File.ReadAllLinesAsync( + Path.Combine(RepositoryLayout.WikiDirectory, file), + cancellationToken + ); for (var index = 0; index < lines.Length; index++) { foreach (var target in RelativeLinkTargets(lines[index])) { - if (!File.Exists(Path.Combine(WikiDirectory, target))) + if (!File.Exists(Path.Combine(RepositoryLayout.WikiDirectory, target))) offenders.Add($"{file}:{index + 1}: '{target}' does not exist"); } } @@ -95,7 +100,7 @@ public async Task Pages_GivenTheirFencedBlocks_AlwaysDeclareALanguage(Cancellati List offenders = []; // Act - foreach (var path in Directory.EnumerateFiles(WikiDirectory, "*.md")) + foreach (var path in Directory.EnumerateFiles(RepositoryLayout.WikiDirectory, "*.md")) { var lines = await File.ReadAllLinesAsync(path, cancellationToken); var inFence = false; @@ -120,7 +125,7 @@ public async Task Pages_GivenTheirFencedBlocks_AlwaysDeclareALanguage(Cancellati static IReadOnlyList ContentPages() => [ .. Directory - .EnumerateFiles(WikiDirectory, "*.md") + .EnumerateFiles(RepositoryLayout.WikiDirectory, "*.md") .Select(path => Path.GetFileNameWithoutExtension(path) ?? string.Empty) .Where(name => name is not "_Sidebar" and not "index") .Order(StringComparer.Ordinal), @@ -152,28 +157,4 @@ static List RelativeLinkTargets(string line) return targets; } - - /// Walks up from the test binary to the repository root. - /// The repository root path. - static string FindRepositoryRoot() - { - DirectoryInfo? directory = new(AppContext.BaseDirectory); - while (directory is not null) - { - if ( - File.Exists(Path.Combine(directory.FullName, "AGENTS.md")) - && Directory.Exists(Path.Combine(directory.FullName, "docs", "wiki")) - ) - { - return directory.FullName; - } - - directory = directory.Parent; - } - - throw new InvalidOperationException( - $"Could not locate the repository root above '{AppContext.BaseDirectory}'. " - + "Expected an AGENTS.md and a docs/wiki folder." - ); - } } diff --git a/src/tests/Repository.UnitTests/InventoryScriptTests.cs b/src/tests/Repository.UnitTests/InventoryScriptTests.cs new file mode 100644 index 0000000..9297493 --- /dev/null +++ b/src/tests/Repository.UnitTests/InventoryScriptTests.cs @@ -0,0 +1,431 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Text.Json.Nodes; + +namespace Purview.EngineeringPlaybook.Repository; + +/// +/// Runs the harvest skill's snapshot script as a real subprocess, replaying recorded input, so the +/// public-only rule is exercised against the code that ships rather than against a copy of it. +/// +/// +/// +/// The script is Node, not a shell, so there is no PowerShell variant and no Bash variant to keep in +/// step — that is the cross-platform answer, and these tests are what stop it drifting back into one. +/// They spawn it the same way on Windows and on Linux: node, the script, and arguments. No +/// shell, no PATH shim, no quoting rules to differ per platform. +/// +/// +/// The gh boundary is replaced with --from and --details rather than with a fake +/// binary. A shim on PATH was tried first and rejected: a non-shell spawn will not resolve a +/// .cmd, so the tests silently reached the real gh and asserted against sixteen live +/// repositories. A seam that can be bypassed without failing is not a seam. +/// +/// +/// Node is a soft dependency. Where it is absent the tests skip with a reason instead of +/// failing, because a .NET pipeline should not require a JavaScript runtime in order to prove a .NET +/// rule. It is not vacuous here: the harvest skill's own just org-inventory target needs it. +/// +/// +public sealed class InventoryScriptTests +{ + /// Whether node can be started at all. + static readonly bool NodeIsAvailable = DetectNode(); + + /// Why the tests skip when Node is missing. + const string NodeMissing = + "node is not on PATH, so the harvest script cannot be started; the assertions were not run."; + + /// The script under test. + static string ScriptPath => + Path.Combine(RepositoryLayout.SkillsDirectory, "purview-org-harvest", "scripts", "inventory.mjs"); + + [Test] + public async Task Help_GivenNoRepository_PrintsTheUsage(CancellationToken cancellationToken) + { + Skip.When(!NodeIsAvailable, NodeMissing); + + // Arrange + using var workspace = Workspace.Create(); + + // Act + var result = await RunAsync(["--help"], cancellationToken); + + // Assert + await Assert.That(result.ExitCode).IsEqualTo(0); + await Assert.That(result.StandardOutput).Contains("print the drift"); + } + + [Test] + public async Task Arguments_GivenAnUnknownOne_FailsRatherThanGuessing(CancellationToken cancellationToken) + { + Skip.When(!NodeIsAvailable, NodeMissing); + + // Arrange + using var workspace = Workspace.Create(); + + // Act + // A typo that silently fell back to printing would be read as "no drift". + var result = await RunAsync(["--wrote"], cancellationToken); + + // Assert + await Assert.That(result.ExitCode).IsEqualTo(1); + await Assert.That(result.StandardError).Contains("Unknown argument"); + } + + [Test] + public async Task Snapshot_GivenAMixedOrganisation_RecordsOnlyThePublicRepositories( + CancellationToken cancellationToken + ) + { + Skip.When(!NodeIsAvailable, NodeMissing); + + // Arrange + using var workspace = Workspace.Create(); + await workspace.PrepareAsync( + Membership( + ("public-one", "PUBLIC", false), + ("public-two", "PUBLIC", false), + ("private-one", "PRIVATE", false), + ("archived-one", "PUBLIC", true) + ), + ["public-one", "public-two"], + cancellationToken + ); + + // Act + // The organisation the token can see contains private and archived repositories. Neither may + // reach a file that is committed to a public repository. + var result = await RunAsync(Replay(workspace, "--write", "--out", workspace.SnapshotPath), cancellationToken); + + // Assert + await Assert.That(result.ExitCode).IsEqualTo(0).Because(result.StandardError); + var snapshot = await workspace.ReadSnapshotAsync(cancellationToken); + var recorded = snapshot["repositories"]!.AsArray(); + + await Assert + .That(recorded.Select(entry => entry!["name"]!.GetValue()).ToArray()) + .IsEquivalentTo(["public-one", "public-two"]); + + foreach (var entry in recorded) + { + await Assert.That(entry!["visibility"]!.GetValue()).IsEqualTo("public"); + await Assert.That(entry["archived"]!.GetValue()).IsFalse(); + await Assert.That(entry["commitSha"]!.GetValue().Length).IsEqualTo(40); + } + } + + [Test] + public async Task Snapshot_GivenAPrivateEntryInTheTarget_IsRefusedAndLeftAlone(CancellationToken cancellationToken) + { + Skip.When(!NodeIsAvailable, NodeMissing); + + // Arrange + // A merge carries forward what the file already held, so an entry a hand edit left behind + // would survive every regeneration. The writer must refuse instead of persisting it. + using var workspace = Workspace.Create(); + await workspace.PrepareAsync(Membership(("public-one", "PUBLIC", false)), ["public-one"], cancellationToken); + await workspace.WriteSnapshotAsync( + Snapshot("private-one", "private"), + withByteOrderMark: false, + cancellationToken + ); + var before = await File.ReadAllBytesAsync(workspace.SnapshotPath, cancellationToken); + + // Act + var result = await RunAsync(Replay(workspace, "--write", "--out", workspace.SnapshotPath), cancellationToken); + + // Assert + await Assert.That(result.ExitCode).IsEqualTo(1); + await Assert.That(result.StandardError).Contains("Refusing"); + await Assert + .That(await File.ReadAllBytesAsync(workspace.SnapshotPath, cancellationToken)) + .IsEquivalentTo(before); + } + + [Test] + public async Task Print_GivenASnapshotWithAByteOrderMark_StillReadsIt(CancellationToken cancellationToken) + { + Skip.When(!NodeIsAvailable, NodeMissing); + + // Arrange + using var workspace = Workspace.Create(); + await workspace.PrepareAsync(Membership(("public-one", "PUBLIC", false)), ["public-one"], cancellationToken); + + // The snapshot is the one file in this workflow a person is likely to open and re-save. + await workspace.WriteSnapshotAsync( + Snapshot("public-one", "public"), + withByteOrderMark: true, + cancellationToken + ); + + // Act + var result = await RunAsync(Replay(workspace, "--print", "--out", workspace.SnapshotPath), cancellationToken); + + // Assert + await Assert.That(result.ExitCode).IsEqualTo(0).Because(result.StandardError); + await Assert.That(result.StandardError).IsEmpty(); + await Assert.That(result.StandardOutput).Contains("public, non-archived repositories"); + } + + [Test] + public async Task Print_GivenAChangedCommit_ReportsItAsDrift(CancellationToken cancellationToken) + { + Skip.When(!NodeIsAvailable, NodeMissing); + + // Arrange + using var workspace = Workspace.Create(); + await workspace.PrepareAsync(Membership(("public-one", "PUBLIC", false)), ["public-one"], cancellationToken); + await workspace.WriteSnapshotAsync( + Snapshot("public-one", "public"), + withByteOrderMark: false, + cancellationToken + ); + + // Act + // Drift is reported and the exit code stays 0: a stale snapshot is a review obligation, not a + // red build for whoever happened to push next. + var result = await RunAsync(Replay(workspace, "--print", "--out", workspace.SnapshotPath), cancellationToken); + + // Assert + await Assert.That(result.ExitCode).IsEqualTo(0); + await Assert.That(result.StandardOutput).Contains("commitSha"); + } + + /// Appends the replay flags, so a run reads recorded input instead of the network. + /// The sandbox holding the recorded files. + /// The run's own arguments. + /// The full argument list. + static string[] Replay(Workspace workspace, params string[] arguments) => + [.. arguments, "--from", workspace.MembershipPath, "--details", workspace.DetailsPath]; + + /// Runs the script as a subprocess, with no shell and no per-platform quoting. + /// The script's arguments. + /// Cancels the run. + /// The exit code and both streams. + static async Task RunAsync(IReadOnlyList arguments, CancellationToken cancellationToken) + { + ProcessStartInfo start = new("node") + { + WorkingDirectory = RepositoryLayout.Root, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + start.ArgumentList.Add(ScriptPath); + foreach (var argument in arguments) + { + start.ArgumentList.Add(argument); + } + + using var process = Process.Start(start) ?? throw new InvalidOperationException("node could not be started."); + + var standardOutput = process.StandardOutput.ReadToEndAsync(cancellationToken); + var standardError = process.StandardError.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken); + + return new Result(process.ExitCode, await standardOutput, await standardError); + } + + /// Builds a gh repo list --json response. + /// Name, visibility and archived flag for each repository. + /// The JSON array the CLI would have printed. + static string Membership(params (string Name, string Visibility, bool Archived)[] repositories) + { + JsonArray listed = + [ + .. repositories.Select(repository => + (JsonNode?) + new JsonObject + { + ["name"] = repository.Name, + ["visibility"] = repository.Visibility, + ["isArchived"] = repository.Archived, + ["pushedAt"] = "2026-11-03T00:00:00Z", + ["defaultBranchRef"] = new JsonObject { ["name"] = "main" }, + } + ), + ]; + + return listed.ToJsonString(); + } + + /// Builds a snapshot document holding one entry, with a deliberately stale SHA. + /// The repository name. + /// The visibility to record, public or not. + /// The JSON document. + static string Snapshot(string name, string visibility) + { + JsonObject document = new() + { + ["schemaVersion"] = 1, + ["org"] = "purview-dev", + ["branch"] = "main", + ["fetchedAt"] = "2026-11-03T00:00:00Z", + ["repositories"] = new JsonArray + { + new JsonObject + { + ["name"] = name, + ["visibility"] = visibility, + ["archived"] = false, + ["commitSha"] = new string('0', 40), + ["pushedAt"] = "2026-11-03T00:00:00Z", + ["docsDir"] = null, + ["docsPages"] = 0, + ["skills"] = new JsonArray(), + ["agentGuidance"] = new JsonArray(), + ["concerns"] = new JsonArray(), + }, + }, + }; + + return document.ToJsonString(); + } + + /// Builds the recorded commits and file lists that --details reads. + /// The repositories to describe. + /// The JSON object the script expects. + static string Details(IReadOnlyList repositories) + { + JsonObject recorded = []; + foreach (var name in repositories) + { + // A run records what the organisation said at the time, so the tree and the SHA are + // supplied together; one without the other would describe a repository nobody read. + recorded[name] = new JsonObject + { + ["commitSha"] = new string('a', 40), + ["paths"] = new JsonArray { "docs/wiki/Home.md", $"skills/{name}-skill/SKILL.md" }, + }; + } + + return recorded.ToJsonString(); + } + + /// Reports whether node can be started. + /// True when it runs and exits cleanly. + static bool DetectNode() + { + try + { + using var process = Process.Start( + new ProcessStartInfo("node") + { + ArgumentList = { "--version" }, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + } + ); + + if (process is null) + { + return false; + } + + process.WaitForExit(); + return process.ExitCode == 0; + } + catch (Win32Exception) + { + return false; + } + } + + /// One script run. + /// The process exit code. + /// What it wrote to stdout. + /// What it wrote to stderr. + sealed record Result(int ExitCode, string StandardOutput, string StandardError); + + /// A temporary directory holding the recorded input and the snapshot under test. + /// + /// Nothing here is platform-specific: the files are JSON, and the script reads them the same way + /// everywhere. That is the whole point of replacing a fake binary with recorded input. + /// + sealed class Workspace : IDisposable + { + Workspace(string root) + { + Root = root; + MembershipPath = Path.Combine(root, "membership.json"); + DetailsPath = Path.Combine(root, "details.json"); + SnapshotPath = Path.Combine(root, "snapshot.json"); + } + + /// The sandbox root. + internal string Root { get; } + + /// Where the recorded gh repo list response is written. + internal string MembershipPath { get; } + + /// Where the recorded commits and file lists are written. + internal string DetailsPath { get; } + + /// The snapshot the script reads and writes. + internal string SnapshotPath { get; } + + /// Creates an empty sandbox. + /// The sandbox; dispose it to remove the directory. + internal static Workspace Create() + { + var root = Path.Combine(Path.GetTempPath(), $"purview-inventory-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + + return new Workspace(root); + } + + /// Writes the recorded input a replay reads. + /// The gh repo list --json response. + /// The repositories the details file describes. + /// Cancels the writes. + /// A task that completes when both files are written. + internal async Task PrepareAsync( + string membership, + IReadOnlyList detailed, + CancellationToken cancellationToken + ) + { + await File.WriteAllTextAsync(MembershipPath, membership, cancellationToken); + await File.WriteAllTextAsync(DetailsPath, Details(detailed), cancellationToken); + } + + /// Writes the snapshot the script will read. + /// The document. + /// Whether to prefix a UTF-8 byte-order mark. + /// Cancels the write. + /// A task that completes when the file is written. + internal async Task WriteSnapshotAsync(string json, bool withByteOrderMark, CancellationToken cancellationToken) + { + // UTF8Encoding(true) writes the BOM that an editor would; the encode is deliberate here. + System.Text.UTF8Encoding encoding = new(withByteOrderMark); + await File.WriteAllTextAsync(SnapshotPath, json, encoding, cancellationToken); + } + + /// Reads the snapshot the script wrote. + /// Cancels the read. + /// The parsed document. + internal async Task ReadSnapshotAsync(CancellationToken cancellationToken) => + JsonNode.Parse(await File.ReadAllTextAsync(SnapshotPath, cancellationToken)) + ?? throw new InvalidOperationException($"'{SnapshotPath}' is empty."); + + /// + public void Dispose() + { + try + { + Directory.Delete(Root, recursive: true); + } + catch (IOException) + { + // A leaked temp directory is not worth failing a passing test over. + } + catch (UnauthorizedAccessException) + { + // Same. + } + } + } +} diff --git a/src/tests/Repository.UnitTests/OrgInventoryTests.cs b/src/tests/Repository.UnitTests/OrgInventoryTests.cs new file mode 100644 index 0000000..aa95b9b --- /dev/null +++ b/src/tests/Repository.UnitTests/OrgInventoryTests.cs @@ -0,0 +1,317 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Purview.EngineeringPlaybook.Repository; + +/// +/// Asserts that the committed snapshot of the organisation, .config/org-inventory.json, is +/// well formed and that every repository the wiki names is in it. +/// +/// +/// +/// The snapshot is what makes the harvest rule checkable without a network call. A repository that +/// was renamed, archived or made private drops out of the organisation while every page citing it +/// keeps rendering, so the only symptom is a link a reader has to click to discover it is dead. +/// +/// +/// These tests are offline deliberately: the pipeline has the file, not the organisation, and a test +/// that called GitHub would fail whenever the network did. Whether the snapshot is current is +/// therefore a review obligation — just org-inventory reports the drift, and this file does +/// not pretend to. See docs/wiki/Cross-Repository-Harvest.md. +/// +/// +public sealed partial class OrgInventoryTests +{ + [GeneratedRegex(@"(?:github\.com|raw\.githubusercontent\.com)/purview-dev/(?[A-Za-z0-9._-]+)")] + private static partial Regex RepositoryUrlPattern(); + + [GeneratedRegex(@"(?[A-Za-z0-9._-]+)")] + private static partial Regex RepositoryNamePattern(); + + [GeneratedRegex(@"^[0-9a-f]{40}$")] + private static partial Regex CommitShaPattern(); + + /// Directory names that hold build output or tool state, never published content. + static readonly string[] ExcludedDirectories = + [ + ".git", + ".purview", + ".tools", + ".vs", + "artifacts", + "bin", + "node_modules", + "obj", + "TestResults", + ]; + + /// The extensions this repository publishes as text. + static readonly string[] TextExtensions = + [ + ".cs", + ".csproj", + ".json", + ".js", + ".md", + ".mjs", + ".mts", + ".props", + ".ps1", + ".sh", + ".slnx", + ".targets", + ".ts", + ".txt", + ".xml", + ".yaml", + ".yml", + ]; + + [Test] + public async Task Snapshot_GivenEveryEntry_IsAPublicRepositoryThatIsNotArchived(CancellationToken cancellationToken) + { + // Arrange + using var snapshot = await ReadSnapshotAsync(cancellationToken); + List offenders = []; + + // Act + // A private repository may shape a rule, but it is never published, and it is never recorded + // here: the snapshot is committed to a public repository. An archived one is no longer a + // source of anything. + foreach (var repository in Repositories(snapshot.RootElement)) + { + var name = Name(repository); + var visibility = repository.GetProperty("visibility").GetString(); + var archived = repository.GetProperty("archived").GetBoolean(); + + if (!string.Equals(visibility, "public", StringComparison.Ordinal) || archived) + { + offenders.Add($"{name}: visibility '{visibility}', archived {archived}"); + } + } + + // Assert + await Assert + .That(offenders) + .IsEmpty() + .Because( + "the snapshot is committed to a public repository, so it records public, unarchived " + + $"ones only: {string.Join("; ", offenders)}" + ); + } + + [Test] + public async Task Snapshot_GivenEveryEntry_PinsTheBranchAtACommit(CancellationToken cancellationToken) + { + // Arrange + using var snapshot = await ReadSnapshotAsync(cancellationToken); + var root = snapshot.RootElement; + List offenders = []; + + // Act + // A snapshot without a SHA is a claim about "recently", and nothing can be re-read at + // "recently". The branch is recorded too, because a convention read at a different branch is + // a different convention. + var branch = root.GetProperty("branch").GetString(); + if (!string.Equals(branch, "main", StringComparison.Ordinal)) + { + offenders.Add($"the snapshot reads branch '{branch}' rather than 'main'"); + } + + var fetchedAt = root.GetProperty("fetchedAt").GetString(); + if (!DateTimeOffset.TryParse(fetchedAt, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out _)) + { + offenders.Add($"fetchedAt '{fetchedAt}' is not a parseable RFC 3339 timestamp"); + } + + foreach (var repository in Repositories(root)) + { + var sha = repository.GetProperty("commitSha").GetString() ?? string.Empty; + if (!CommitShaPattern().IsMatch(sha)) + { + offenders.Add($"{Name(repository)}: commitSha '{sha}' is not a 40-character SHA"); + } + + var pushedAt = repository.GetProperty("pushedAt").GetString(); + if (string.IsNullOrEmpty(pushedAt)) + { + offenders.Add($"{Name(repository)}: pushedAt is missing"); + } + } + + // Assert + await Assert.That(offenders).IsEmpty().Because(string.Join("; ", offenders)); + } + + [Test] + public async Task Files_GivenARepositoryTheyName_ResolveToASnapshotEntry(CancellationToken cancellationToken) + { + // Arrange + using var snapshot = await ReadSnapshotAsync(cancellationToken); + var known = Repositories(snapshot.RootElement).Select(Name).ToHashSet(StringComparer.Ordinal); + List offenders = []; + + // Act + // Two failures, one assertion. GitHub redirects a renamed repository, so a stale name still + // resolves in a browser and nothing else notices. And a repository the snapshot does not + // contain is either private or gone - either way, naming it publishes something this + // repository has no business publishing. Skills count: a skill is installed into somebody + // else's agent. + foreach (var path in PublishedTextFiles()) + { + var lines = await File.ReadAllLinesAsync(path, cancellationToken); + for (var index = 0; index < lines.Length; index++) + { + foreach (var name in NamedRepositories(lines[index])) + { + if (!known.Contains(name)) + { + offenders.Add($"{Path.GetRelativePath(RepositoryLayout.Root, path)}:{index + 1}: '{name}'"); + } + } + } + } + + // Assert + await Assert + .That(offenders) + .IsEmpty() + .Because( + "every repository this repository names - in a page, a skill, a workflow or a config - " + + "must be one the snapshot recorded as public and unarchived, so a rename, an " + + "archive or a private repository is caught here rather than by a reader: " + + string.Join("; ", offenders) + ); + } + + [Test] + public async Task Skills_GivenTheirFrontmatter_NameTheRepositoryThatPublishesThem( + CancellationToken cancellationToken + ) + { + // Arrange + List offenders = []; + + // Act + // The skills CLI clones the repository named in the source it was given, and the frontmatter + // is what a reader of an installed skill can check. A skill that names no repository is + // guidance with no owner. + foreach (var directory in Directory.EnumerateDirectories(RepositoryLayout.SkillsDirectory)) + { + var manifest = Path.Combine(directory, "SKILL.md"); + if (!File.Exists(manifest)) + { + continue; + } + + var frontmatter = Frontmatter(await File.ReadAllTextAsync(manifest, cancellationToken)); + if (!frontmatter.Contains("repository: purview-dev/engineering-playbook", StringComparison.Ordinal)) + { + offenders.Add(Path.GetFileName(directory)); + } + } + + // Assert + await Assert + .That(offenders) + .IsEmpty() + .Because( + "these skills do not declare metadata.repository in their frontmatter: " + string.Join(", ", offenders) + ); + } + + /// Every published text file in the repository. + /// The file paths, as full paths. + /// + /// The snapshot itself is excluded: it is generated from the organisation, so checking it against + /// itself proves nothing. Everything else is read, because a repository named in a skill reaches + /// somebody else's agent and a repository named in a workflow reaches a public build log — the + /// wiki is the loudest surface, not the only one. + /// + static IEnumerable PublishedTextFiles() + { + Stack pending = new([RepositoryLayout.Root]); + while (pending.Count > 0) + { + var directory = pending.Pop(); + foreach (var child in Directory.EnumerateDirectories(directory)) + { + if (!ExcludedDirectories.Contains(Path.GetFileName(child), StringComparer.OrdinalIgnoreCase)) + { + pending.Push(child); + } + } + + foreach (var file in Directory.EnumerateFiles(directory)) + { + if ( + TextExtensions.Contains(Path.GetExtension(file), StringComparer.OrdinalIgnoreCase) + && !string.Equals(file, RepositoryLayout.OrgInventoryPath, StringComparison.OrdinalIgnoreCase) + ) + { + yield return file; + } + } + } + } + + /// Reads the committed snapshot. + /// Cancels the read. + /// The parsed snapshot; the caller disposes it. + static async Task ReadSnapshotAsync(CancellationToken cancellationToken) => + JsonDocument.Parse(await File.ReadAllTextAsync(RepositoryLayout.OrgInventoryPath, cancellationToken)); + + /// The snapshot's repositories. + /// The snapshot document's root element. + /// The repository entries. + static JsonElement.ArrayEnumerator Repositories(JsonElement root) => + root.GetProperty("repositories").EnumerateArray(); + + /// One repository entry's name. + /// The repository entry. + /// The repository name. + static string Name(JsonElement repository) => repository.GetProperty("name").GetString() ?? string.Empty; + + /// Reads the repository names a line of markdown mentions. + /// The markdown line. + /// The distinct names, deduplicated because a URL also matches the bare-name pattern. + static HashSet NamedRepositories(string line) + { + HashSet names = new(StringComparer.Ordinal); + foreach (var pattern in new[] { RepositoryUrlPattern(), RepositoryNamePattern() }) + { + foreach (Match match in pattern.Matches(line)) + { + // A name that ends a sentence picks up the full stop, and a clone URL ends in `.git`. + // A repository name ends with neither. + var name = match.Groups["repo"].Value.TrimEnd('.'); + if (name.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + { + name = name[..^4]; + } + + if (name.Length > 0) + { + names.Add(name); + } + } + } + + return names; + } + + /// Reads a manifest's YAML frontmatter. + /// The manifest's text. + /// The frontmatter block, or an empty string when there is none. + static string Frontmatter(string text) + { + if (!text.StartsWith("---", StringComparison.Ordinal)) + { + return string.Empty; + } + + var end = text.IndexOf("\n---", 3, StringComparison.Ordinal); + return end < 0 ? string.Empty : text[3..end]; + } +} diff --git a/src/tests/Repository.UnitTests/RepositoryLayout.cs b/src/tests/Repository.UnitTests/RepositoryLayout.cs new file mode 100644 index 0000000..e627971 --- /dev/null +++ b/src/tests/Repository.UnitTests/RepositoryLayout.cs @@ -0,0 +1,55 @@ +namespace Purview.EngineeringPlaybook.Repository; + +/// +/// Where the files these tests assert on live, resolved from the test binary rather than from the +/// current directory, so a test behaves the same in an IDE run and on a build agent. +/// +/// +/// One walk, shared. Two test classes that each grew their own copy of it would be the duplication +/// this playbook tells a repository to remove — and the copy that drifts is the one that finds the +/// wrong root and passes against it. +/// +static class RepositoryLayout +{ + /// The repository root. + /// + /// Found by walking up from to the first directory that has + /// both an AGENTS.md and a docs/wiki folder — two things a checkout of this + /// repository cannot be missing. + /// + internal static string Root { get; } = FindRoot(); + + /// The playbook's pages. + internal static string WikiDirectory => Path.Combine(Root, "docs", "wiki"); + + /// The committed snapshot of the organisation. + internal static string OrgInventoryPath => Path.Combine(Root, ".config", "org-inventory.json"); + + /// The skills this repository authors. + internal static string SkillsDirectory => Path.Combine(Root, "skills"); + + /// Walks up from the test binary to the repository root. + /// The repository root path. + /// No directory above the test binary looks like this repository. + static string FindRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if ( + File.Exists(Path.Combine(directory.FullName, "AGENTS.md")) + && Directory.Exists(Path.Combine(directory.FullName, "docs", "wiki")) + ) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException( + $"Could not locate the repository root above '{AppContext.BaseDirectory}'. " + + "Expected an AGENTS.md and a docs/wiki folder." + ); + } +}