From 536b8e01fa4ea8ec04447cdfe30e81a9f1677c3f Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 3 Nov 2026 15:55:58 +0000 Subject: [PATCH] feat: harvest every public repository at main before adopting a convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A convention here was previously justified by whichever repository the author happened to have cloned. Two local clones sit under names their remotes no longer use, four public repositories have no clone at all, and a renamed repository keeps resolving through a redirect, so a stale citation looks healthy until it is compared with the organisation. - `skills/purview-org-harvest`: the procedure, the owner matrix, and one Node script that snapshots every public purview-dev repository at `main` - `.config/org-inventory.json`: the generated snapshot — 16 public repositories, no private one and no archived one, ever - `OrgInventoryTests`: the snapshot is public-only, every repository any published file names exists in it, and every skill names its publisher - `InventoryScriptTests`: the real script, spawned with no shell and replayed with `--from`/`--details`, so it runs the same on Windows and Linux - `docs/wiki/Cross-Repository-Harvest.md`: the rule, the failures it prevents, what enforces it, and what a smaller repository does instead - `docs/wiki/Verification-Model.md`: a gate is not bypassed. Recorded because it was broken while building the rest of this commit — `git push --no-verify` was used on a sibling repository's branch when its hook hung. Also carries Purview.BuildSdk 1.2.0 and the SDK pin to 11.0.100-rc.3 from the working tree, and refreshes the snapshot taken before those merges. No version bump: the change ships skills and documentation, so the release eligibility is a REL002 skip rather than a new tag. Verified with `just pipeline-pr` (90 tests, 85 passed, 5 data-driven skips, 0 failed, 0 warnings), `dotnet csharpier check` clean, and both required checks green on the pull request. --- .config/org-inventory.json | 381 ++++++++++++++ .gitignore | 5 + AGENTS.md | 14 +- Directory.Packages.props | 6 +- Justfile | 16 + docs/wiki/Agent-Guidance-Skills.md | 1 + docs/wiki/Cross-Repository-Harvest.md | 136 +++++ docs/wiki/Documentation-Convention.md | 14 +- docs/wiki/Home.md | 7 +- docs/wiki/Verification-Model.md | 41 ++ docs/wiki/_Sidebar.md | 1 + global.json | 4 +- skills/purview-architecture-testing/SKILL.md | 1 + skills/purview-architecture/SKILL.md | 1 + skills/purview-org-harvest/SKILL.md | 197 ++++++++ .../references/best-in-class.md | 70 +++ .../references/harvest-commands.md | 121 +++++ .../purview-org-harvest/scripts/inventory.mjs | 465 ++++++++++++++++++ skills/purview-persistence/SKILL.md | 1 + skills/purview-telemetry/SKILL.md | 3 +- .../DocumentationTests.cs | 49 +- .../InventoryScriptTests.cs | 431 ++++++++++++++++ .../Repository.UnitTests/OrgInventoryTests.cs | 317 ++++++++++++ .../Repository.UnitTests/RepositoryLayout.cs | 55 +++ 24 files changed, 2293 insertions(+), 44 deletions(-) create mode 100644 .config/org-inventory.json create mode 100644 docs/wiki/Cross-Repository-Harvest.md create mode 100644 skills/purview-org-harvest/SKILL.md create mode 100644 skills/purview-org-harvest/references/best-in-class.md create mode 100644 skills/purview-org-harvest/references/harvest-commands.md create mode 100644 skills/purview-org-harvest/scripts/inventory.mjs create mode 100644 src/tests/Repository.UnitTests/InventoryScriptTests.cs create mode 100644 src/tests/Repository.UnitTests/OrgInventoryTests.cs create mode 100644 src/tests/Repository.UnitTests/RepositoryLayout.cs 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." + ); + } +}