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."
+ );
+ }
+}