FE-1322: Generate Petrinaut architecture docs from in-code annotations - #9165
FE-1322: Generate Petrinaut architecture docs from in-code annotations#9165kube wants to merge 10 commits into
Conversation
Architecture docs rot because nothing fails when they stop being true. `@local/petrinaut-arch-docs` extracts the architecture from annotations that live next to the code they describe, and CI fails when a declaration stops matching reality. Two inputs, each in its natural home: - Folder `README.md` frontmatter declares a layer, and the prose below it becomes that layer's page — folder docs that already exist turn into architecture pages for free. - `@boundary`, `@invariant` and `@seam` doc-comment tags attach facts to the specific code that upholds them. Files with no annotation inherit from the nearest declaring ancestor, so a few dozen declarations cover several hundred files. The output is a portable bundle rather than a site: `architecture.json` (the model), `architecture.md` (the whole architecture in one file, which is the cheapest read for an agent), `llms.txt`, `manifest.json` (a page tree so a host can build navigation without crawling), MDX pages, and D2 diagram sources. Generated MDX is YAML frontmatter plus plain CommonMark with no JSX, which is what lets one bundle render in Astro, in hash.dev's Next.js MDX pipeline, and as plain text. `lint:arch-docs` runs in CI and fails on: a source file no declaration covers, a layer id implying an undeclared ancestor, a duplicate layer or two declarations on one folder, a malformed tag, a `@seam` that is no longer an export, a dependency violating a declared rule, and a committed bundle that no longer matches the source. It needs no `d2`, comparing the text artefacts rather than re-rendering SVGs. Edges carry only `crossesPackage` as a boundary fact. Which runtime boundaries an import crosses cannot be read off a static import graph — a module importing into a worker-boundary layer is how you obtain the module, not evidence that a thread hop occurs — so that is the one such fact that is always true when reported. yarn.lock covers this package's dependencies and those of the docs site added in a later commit.
Replaces the hand-maintained path-to-layer mapping with declarations that sit next to the code they describe: 37 layers across petrinaut-core and petrinaut, covering 412 source files. Most declarations are frontmatter added to READMEs that already existed and already explained their folder, so their prose now doubles as the layer's documentation. Where a folder had a barrel entry file and no README, `@layerRoot` on that file does the same job. Fifteen boundaries and 25 invariants are recorded against the specific files that uphold them. Three dependency rules are now enforced against the real import graph. The substantive one is that `react` must not depend on `ui`: state providers stay mountable without rendering the editor. That already held — 0 imports in that direction against 251 the other way — so the rule locks in an existing property rather than asking for new work. Retires `scripts/generate-dependency-diagrams.mjs`, which held the architecture as ~180 lines of `if (path.startsWith(...))` far from the code, with a fallback that silently mis-bucketed anything renamed. It also hard-coded seven of petrinaut-core's ten entry points, so imports through `./ai`, `./optimization` and `./compiled-model` resolved to nothing and were absent from the diagrams entirely; aliases are now derived from the package's `exports`. Its generated `.d2`/`.svg` output and `dependency-diagrams.md` go with it, and `dependency-cruiser` is no longer a petrinaut-core dependency. The hand-written HTML in `docs/architecture/` is deliberately left in place — the content is valuable but unverified, and its custom lane-and-box CSS needs rewriting page by page. A README there records that status and points at the generated docs for facts about the current shape of the system.
The bundle is committed because it is what CI diffs against to detect drift, and what a host embedding these docs consumes. Regenerate with `mise run doc:architecture` after changing annotations or moving code. `@apps/petrinaut-docs` is a Starlight site that owns no content: every page comes from the bundle. That is deliberate — the bundle has to render in a host that did not generate it, so this site is a portability test as much as a way to read the docs, and anything that only works here is a bug in the bundle. It builds its sidebar from `manifest.json` rather than from Starlight-shaped frontmatter, which is what keeps the bundle framework neutral. `trailingSlash: "never"` and `build.format: "file"` are load-bearing. Inter-page links in the bundle are relative and assume a slug maps to a URL with no trailing slash; serving `/architecture/core/` instead would resolve them one level too deep. The bundle is copied into the app rather than loaded in place because `astro dev` resolves an MDX page's relative image paths against the project root, so a diagram referenced from outside the project cannot be found. Copying is also what an embedding host does. `installConfig.hoistingLimits` nests this app's dependencies. Astro's generated prerender entry resolves `cookie` from the app's build output, which would otherwise reach the root-hoisted `cookie@0.7.2` that `express` pins and fail on a missing `parseCookie` export. Nesting keeps Astro on its own `cookie@2.x` without changing hoisting for the rest of the monorepo. There is no `lint:tsc` for the app: everything in it is `.mjs`, so `astro check` would pull in `@astrojs/check` and `typescript` to check almost nothing. `astro build` is the real check.
oxfmt and the architecture generator both claimed ownership of the bundle's MDX, which made the format check and the drift check mutually exclusive: formatting the bundle made a fresh generate look like drift, and regenerating it made the format check fail. The generator owns that output byte-for-byte — CI diffs a fresh build against the committed files to detect drift — so the bundle is now ignored by the formatter, alongside the other autogenerated paths. Authored pages in `content/` are still formatted; they are inputs, and the bundle copies them verbatim.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
| export const GENERATED_ORDER_BASE = 1000; | ||
|
|
||
| const escapeTableCell = (text: string): string => | ||
| text.replace(/\|/gu, "\\|").replace(/\n/gu, " "); |
|
|
||
| const lineStarts = lineStartOffsets(sourceText); | ||
|
|
||
| for (const match of sourceText.matchAll(blockCommentPattern)) { |
|
|
||
| const fields = match[1] ?? ""; | ||
| const read = (key: string): string | null => { | ||
| const found = new RegExp(`^${key}\\s*:\\s*(.+)$`, "mu").exec(fields); |
Merging this PR will degrade performance by 15.38%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ❌ | bit_matrix/dense/iter_row[64] |
140.8 ns | 170 ns | -17.16% |
| ❌ | bit_matrix/dense/iter_row[200] |
185.8 ns | 215 ns | -13.57% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing cf/fe-1322-generate-petrinaut-architecture-docs-from-in-code (f7a7839) with main (2d99645)1
Footnotes
The bundle is derived entirely from the annotations in the source and the authored pages in `content/`, so committing it meant reviewing every change twice and resolving conflicts in generated files. It is now git-ignored build output. This removes the need for the drift check that compared a fresh build against the committed copy: with nothing stored, nothing can be stale. What `lint:arch-docs` still enforces is the part that was always the real value — unannotated files, undeclared ancestors, malformed tags, dead `@seam`s, and dependency rules the import graph violates. It builds the bundle in memory and discards it. The docs site now regenerates the bundle rather than assuming a committed one is present, so `dev` and `build` share one code path and cannot render a stale copy. One consequence worth naming: a reviewer can no longer see the rendered documentation change in a PR diff. The annotations that produce it are still reviewable, and the docs can be regenerated locally in seconds.
The five hand-written HTML pages in `petrinaut-core/docs/architecture/`
carried genuinely useful detail — the binary frame format, the worker
message protocol, the ack contract, the Monte Carlo memory model — but
nothing verified them, and their custom lane-and-box CSS meant they could
only be read by opening a file in a browser.
They are now authored MDX under `content/simulation/`, bundled alongside
the generated pages and reachable from the docs site. Diagrams that relied
on CSS are plain tables or fenced text blocks, so they render anywhere the
rest of the bundle does.
Migrating surfaced staleness, which was the main argument for moving them:
- The frame-format page said UUID support was designed but not implemented
("the layout machinery is ready; the value plumbing is not"). `uuid` is a
real element type now, with parsing, formatting, namespaced generation
and seeded fallback.
- The sandbox section described only global shadowing plus a constructor
guard "for the duration of the call". The guard now swaps `.constructor`
descriptors on the built-in prototypes and freezes the user-facing
argument.
Everything retained was checked against the code rather than copied:
frame version and header size, the play-mode backpressure profiles, the
worker and Monte Carlo batch defaults, and the string-pool design.
Content the generated pages already own — module maps, per-layer file
lists, responsibilities — was dropped rather than duplicated, so these
pages carry only what an import graph cannot express.
The three inbound references to the HTML now point at the docs site, and
the site nests authored pages by slug directory so the five appear as one
"Simulation" group.
The bundle referenced `diagrams/*.svg` unconditionally, but rendering them needs `d2`, and a failure to render only produced a warning. In any environment without `d2` the result was a bundle pointing at images that were never written, which fails the consuming site's build rather than degrading. `d2` is a declared repo tool, so a `mise install` environment is fine. An environment that installs tools individually is not — the Vercel install script for the Petrinaut website names its tools one by one and does not include it, which is exactly the situation a deployment would hit. The generator now probes for the renderer before emitting pages and omits the diagram images when it is absent, so the bundle is internally consistent either way. `check` skips the probe: it writes nothing, so availability cannot affect its result. Verified in both directions — with `d2`, 8 diagrams and 7 pages embedding them; without it, no SVGs, no references, and the site still builds all 47 pages.
Hand-written guides sat in their own sidebar section, separate from the
generated reference for the same code. Someone reading about the Monte
Carlo layer had no reason to discover the page explaining its memory
model, and vice versa.
An authored page can now name the layer it explains:
attachTo: core.simulation.monte-carlo
which moves it beneath that layer's page, adds it to a "Guides" section
there, and — because nesting follows the slug — places it in the sidebar
next to the layer's sub-layers. The five simulation deep-dives now live
inside the architecture tree rather than beside it.
`attachTo` references a layer; it does not declare one. Declaring layers
from `content/` stays forbidden, and naming a layer that does not exist
fails the check.
This required a way to link between pages that does not depend on where a
page ends up, since `attachTo` decides that:
[the engine](layer:core.simulation.engine)
[memory model](doc:simulation/memory-model)
Both resolve to the correct relative path at emit time, fragments
included, and an unresolved target is a build error rather than a link
that 404s for a reader. Ordinary relative and absolute links are
untouched, so pages that will never move can still use them.
The site's sidebar now nests purely by slug rather than by whether a page
was generated, which is what lets an attached guide appear inside a
generated group at all.
Migrating the HTML pages flattened their diagrams into tables and fenced
text. The lane-and-box thread views, the frame memory map and the message
sequence carried real information in their layout — a byte map read as a
table loses the sense of a single contiguous buffer, which is the point of
the format.
They are back as React components in the bundle, imported by authored
pages:
import { ByteMap } from "@diagrams/byte-map";
`@diagrams/` is rewritten to a real relative path at emit time, for the
same reason as `layer:` and `doc:` — a page's depth depends on `attachTo`.
An import naming a component that does not exist fails the check.
Four components cover every diagram the old pages had: `lanes` (parallel
columns of boxes), `pipeline` (a numbered chain), `byte-map` (an offset
gutter with typed sections) and `sequence` (two actors exchanging
messages). They are data-driven, so a page supplies content and the
component owns presentation.
Two constraints keep the bundle portable, both learned the hard way here:
- **Plain React, no dependencies.** Styling is one stylesheet deriving its
colours from the host's `currentColor`, so it works on light and dark
themes it has never seen. No design system, no Astro, no `next/*`.
- **String props, never JSX.** JSX inside MDX is compiled by the host's MDX
renderer, and handing that to a React component fails at render with
"Objects are not valid as a React child". Props are strings, and
backticks render as `<code>`.
This is the one thing the bundle now asks of a host: a React-capable MDX
pipeline for authored pages. Generated pages stay plain CommonMark, and
`architecture.md` — the single-file artefact for agents — has no
components at all.
`starlight-llms-txt` is dropped rather than worked around: it renders MDX
to text in a container with no React renderer, and its `exclude` option is
accepted but never passed to the `/llms-full.txt` route, so components
were a hard build failure. The site now serves the bundle's own
`architecture.md` and `architecture.json`, which is better anyway — the
machine-readable surface is identical to what any other host would serve.
Also fixes a race the components exposed: `build` and `lint:tsc` each
invoked `sync:bundle`, which wipes and recopies the same directories, so
Turborepo running them concurrently failed intermittently. `sync:bundle`
is now a task both depend on.
CI checks out only the files a package declares a dependency on, so the `@apps/petrinaut-docs` lint job had no `petrinaut-core/src` to scan and failed with ENOENT the moment it ran the generator. The dependency is real — the generator walks those packages' source for annotations — it was simply never written down, because nothing is imported from them. Declaring it as a devDependency of the generator makes the sparse checkout include them and states the coupling honestly. Also dedupes the lockfile, which `lint:yarn-deduplicate` flagged after the Astro and React additions.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #9165 +/- ##
=======================================
Coverage 59.57% 59.57%
=======================================
Files 1413 1413
Lines 138053 138053
Branches 6510 6510
=======================================
+ Hits 82240 82241 +1
+ Misses 54771 54770 -1
Partials 1042 1042 Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| { | ||
| "version": "0.0.1", | ||
| "configurations": [ | ||
| { | ||
| "name": "petrinaut-docs", | ||
| "runtimeExecutable": "yarn", | ||
| "runtimeArgs": [ | ||
| "workspace", | ||
| "@apps/petrinaut-docs", | ||
| "dev", | ||
| "--port", | ||
| "4321" | ||
| ], | ||
| "port": 4321 | ||
| } | ||
| ] | ||
| } |
There was a problem hiding this comment.
@TimDiekmann @CiaranMn what do you think of having this .claude/launch.json in the repo?
Benchmark results
|
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 2002 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 1002 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: high, policies: 3314 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: medium, policies: 1527 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 2078 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 1033 | Flame Graph |
policy_resolution_medium
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 102 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 52 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: high, policies: 269 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: medium, policies: 108 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 133 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 63 | Flame Graph |
policy_resolution_none
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 2 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 2 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 8 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 3 | Flame Graph |
policy_resolution_small
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 52 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 26 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: high, policies: 94 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: medium, policies: 27 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 66 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 29 | Flame Graph |
read_scaling_complete
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_id;one_depth | 1 entities | Flame Graph | |
| entity_by_id;one_depth | 10 entities | Flame Graph | |
| entity_by_id;one_depth | 25 entities | Flame Graph | |
| entity_by_id;one_depth | 5 entities | Flame Graph | |
| entity_by_id;one_depth | 50 entities | Flame Graph | |
| entity_by_id;two_depth | 1 entities | Flame Graph | |
| entity_by_id;two_depth | 10 entities | Flame Graph | |
| entity_by_id;two_depth | 25 entities | Flame Graph | |
| entity_by_id;two_depth | 5 entities | Flame Graph | |
| entity_by_id;two_depth | 50 entities | Flame Graph | |
| entity_by_id;zero_depth | 1 entities | Flame Graph | |
| entity_by_id;zero_depth | 10 entities | Flame Graph | |
| entity_by_id;zero_depth | 25 entities | Flame Graph | |
| entity_by_id;zero_depth | 5 entities | Flame Graph | |
| entity_by_id;zero_depth | 50 entities | Flame Graph |
read_scaling_linkless
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_id | 1 entities | Flame Graph | |
| entity_by_id | 10 entities | Flame Graph | |
| entity_by_id | 100 entities | Flame Graph | |
| entity_by_id | 1000 entities | Flame Graph | |
| entity_by_id | 10000 entities | Flame Graph |
representative_read_entity
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/block/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/book/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/building/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/organization/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/page/v/2
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/person/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/playlist/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/song/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/uk-address/v/1
|
Flame Graph |
representative_read_entity_type
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| get_entity_type_by_id | Account ID: bf5a9ef5-dc3b-43cf-a291-6210c0321eba
|
Flame Graph |
representative_read_multiple_entities
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_property | traversal_paths=0 | 0 | |
| entity_by_property | traversal_paths=255 | 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true | |
| link_by_source_by_property | traversal_paths=0 | 0 | |
| link_by_source_by_property | traversal_paths=255 | 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true |
scenarios
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| full_test | query-limited | Flame Graph | |
| full_test | query-unlimited | Flame Graph | |
| linked_queries | query-limited | Flame Graph | |
| linked_queries | query-unlimited | Flame Graph |
| @@ -0,0 +1,5 @@ | |||
| # License | |||
There was a problem hiding this comment.
Thee docs need switching from AGPLv3 to Apache 2.0/MIT dual-license.
| // The helmet carries the Petrinaut identity, so the title beside it names | ||
| // only what this site is. `replacesTitle: false` keeps both. | ||
| logo: { | ||
| src: "./src/assets/petrinaut-helmet.png", |
| layer: core.hir | ||
| name: HIR compiler | ||
| role: Lowers user-authored TypeScript to a source-spanned IR, then typechecks, lints and emits it | ||
| seams: |
There was a problem hiding this comment.
Can we use a term other than seams? Feels very AI.
|
Nice initiative, I've been thinking about doing something similar. One thing you might consider adding here is drift. Current checks cover facts (ancestors, exports, dep rules, links), but not whether a given sentence is still true; whereas The docs stay pure markdown(bindings live in It might also be a good answer to the Adoption, roughly, according to claude:
|
🌟 What is the purpose of this PR?
Petrinaut's architecture documentation had no mechanism keeping it true. This makes the architecture something you declare next to the code it describes, generates the docs from those declarations, and fails CI when a declaration stops matching reality.
The output is a portable bundle, not a website — so the same artefact renders locally, embeds into
hash.dev/docs/petrinaut, or gets handed to an AI agent.flowchart LR subgraph source["Petrinaut source — versioned"] A["Folder README.md frontmatter<br/><i>layer · role · seams · boundaries</i>"] B["Doc-comment tags<br/><i>@layerRoot @boundary<br/>@invariant @seam</i>"] end subgraph content["content/ — versioned, optional"] C["Authored MDX<br/><i>attachTo: core.simulation</i>"] D["Diagram components<br/><i>.tsx</i>"] end E{{"Extractor<br/>+ dependency-cruiser"}} subgraph bundle["bundle/ — build output, git-ignored"] F["architecture.json · architecture.md · llms.txt<br/>manifest.json · pages/**.mdx · diagrams · components"] end G["Starlight site<br/><i>apps/petrinaut-docs</i>"] H["hash.dev"] I["AI agents"] A --> E B --> E E -->|"real import graph<br/>+ layer model"| F C --> F D --> F F --> G F --> H F --> I🔍 What does this change?
1. The generated part reads annotations automatically
Nothing is registered in a central file. A build walks the packages, reads two kinds of annotation, and resolves the model:
Folder
README.mdfrontmatter declares a layer — and the prose below it becomes that layer's page, so folder READMEs that already explained themselves became architecture pages for free:Doc-comment tags attach facts to the specific code that upholds them, picked up from any block comment in any file:
Files with no annotation inherit from the nearest declaring ancestor. That is what keeps this proportional to the architecture rather than to the file count: ~40 declarations cover 412 files. Layer sizes, dependency edges and boundary tables are then derived from the real TypeScript import graph.
Result: 37 layers, 177 edges, 15 boundaries, 25 invariants — none of it hand-maintained.
2. Hand-written content merges into the generated tree
content/is entirely optional (the system works with the directory absent), and pages there carry the reasoning an import graph cannot express.A page names the layer it explains and moves inside the generated tree, rather than sitting in a separate section:
The layer's page gains a Guides section, and the sidebar nests the guide beside that layer's sub-layers.
Because
attachTodecides where a page ends up, pages link by name and the path is computed at emit time —[text](layer:core.simulation.engine),[text](doc:simulation/memory-model). Unresolved targets fail the build rather than 404ing later.Authored pages may also import diagram components from the bundle (
import { ByteMap } from "@diagrams/byte-map") — lane diagrams, pipelines, the frame byte map, message sequences.3. CI enforces the claims
lint:arch-docsfails on: an unannotated source file, a layer id implying an undeclared ancestor, a duplicate declaration, a malformed tag, a@seamthat is no longer a real export, anattachToor link target that does not resolve, and any dependency violating a rule inarchitecture.config.ts.Three rules are enforced today. The substantive one —
reactmust not depend onui— already held (0 imports against 251 the other way), so it locks in an existing property.4. What this replaces
petrinaut-core/scripts/generate-dependency-diagrams.mjsheld the architecture as ~180 lines ofif (path.startsWith(...))far from the code, with a fallback that silently mis-bucketed anything renamed. It also hard-coded 7 ofpetrinaut-core's 10 entry points, so imports through./ai,./optimizationand./compiled-modelwere absent from the diagrams entirely. Entry points now derive from each package'sexports.docs/architecture/are migrated to authored MDX. Doing so surfaced two stale claims: UUID support described as unimplemented (it is fully implemented), and a sandbox description thinner than the current implementation.🔗 Related links
hash.dev/docs/petrinaut. This produces the exportable artefact that work needs; it publishes nothing itself.Pre-Merge Checklist 🚀
🚢 Has this modified a publishable library?
@hashintel/petrinautandpetrinaut-coreare touched only by comments, READMEs, and removal of a private script and itsdependency-cruiserdevDependency. No runtime code, types or exports change. Happy to add a patch changeset if reviewers would rather the README changes ship.📜 Does this require a change to the docs?
The user-facing guide (
libs/@hashintel/petrinaut/docs/) is untouched — no UI or behaviour changed.AGENTS.mdgains a section on declaring layers and what CI enforces.🕸️ Does this require a change to the Turbo Graph?
turbo.json's have been updated to reflect thisAdds
doc:architecture/lint:arch-docson the generator, andsync:bundle→build/lint:tscon the docs app. Removesdoc:dependency-diagramfrompetrinaut-core.petrinaut-cli,petrinaut-websiteandpetrinaut-opthave no declarations. A TypeScript package is a config entry plus one root declaration; the Python app needs docstring extraction, which is not written.architecture.md— the artefact for agents — has no components.starlight-llms-txtwas dropped, not worked around: it renders MDX in a container with no React renderer, and itsexcludeoption is accepted but never passed to the/llms-full.txtroute. The site serves the bundle's ownarchitecture.mdandarchitecture.jsoninstead.mise run fix:package-jsoncould not run locally (needs a nightly Cargo feature), sopackage.jsonkey ordering was verified by reading the sorter's field list.🐾 Next steps
demo.petrinaut.org/docsis feasible — the SPA has no catch-all rewrite, and an Astrobase: "/docs"build was tested — but it needsd2added to the Vercel install step, and it is worth settling against FE-1157 first so there is one canonical URL.🛡 What tests cover this?
58 tests in
@local/petrinaut-arch-docs:tags.test.ts— tag grammar: separators, multi-line continuation, duplicates, typo suggestions, and that a tag named in prose is not a declaration.frontmatter.test.ts— declarations, defaults, malformed YAML, half-written declarations, CRLF.extract.test.ts— inheritance through undeclared folders,@layeroverrides, uncovered files, stable ordering.check.test.ts— each CI check in both directions: fires when broken, silent when not.emit/mdx.test.ts— link resolution at varying depths, fragments, unresolved targets.Existing suites unaffected: 842 (
petrinaut-core), 187 (petrinaut).❓ How to test this?
yarn workspace @apps/petrinaut-docs dev # http://localhost:4321Confirm the checks hold, and hold in either order — formatting and generation both used to claim the bundle's files:
Then break something and confirm it is caught: change a
role:in any layer-declaring README, point anattachToat a layer that does not exist, or referencelayer:core.nonexistent— each failslint:arch-docswith the offending file named.For the AI-facing side, read
libs/@local/petrinaut-arch-docs/bundle/architecture.md— the whole architecture in one file.