Skip to content

FE-1322: Generate Petrinaut architecture docs from in-code annotations - #9165

Closed
kube wants to merge 23 commits into
mainfrom
cf/fe-1322-generate-petrinaut-architecture-docs-from-in-code
Closed

FE-1322: Generate Petrinaut architecture docs from in-code annotations#9165
kube wants to merge 23 commits into
mainfrom
cf/fe-1322-generate-petrinaut-architecture-docs-from-in-code

Conversation

@kube

@kube kube commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

🌟 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 the code.

The output is a portable bundle, not a website — the same artefact renders locally, embeds into hash.dev/docs/petrinaut, or gets handed to an AI agent.

flowchart LR
  A["@layerRoot + @role<br/><i>in doc comments and README frontmatter</i>"]
  C["content/<br/><i>authored MDX, optional</i>"]
  E{{"Extractor<br/>+ dependency-cruiser"}}
  D{{"d2"}}
  F["bundle/<br/><i>architecture.json · architecture.md<br/>pages · diagrams · components</i>"]
  G["Starlight site"]
  H["hash.dev"]
  I["AI agents"]

  A --> E
  E -->|"layer model +<br/>real import graph"| F
  E --> D
  D -->|"44 SVGs"| F
  C --> F
  F --> G
  F --> H
  F --> I
Loading

🔍 What does this change?

1. Two annotations describe the whole architecture

A declaration is two lines — which layer a folder forms, and what it is for:

/**
 * @layerRoot core.simulation.monte-carlo
 * @role Runs many simulations with bounded frame memory
 */

A folder README.md can declare the same thing in frontmatter, and its prose becomes that layer's page:

---
layer: core.simulation.monte-carlo
role: Runs many simulations with bounded frame memory
---

Files with no annotation inherit from the nearest declaring ancestor, which is what keeps this proportional to the architecture rather than to the file count: 37 declarations cover 412 files. Everything else — layer sizes, 177 dependency edges, the parent/child tree — is derived from the real TypeScript import graph.

The vocabulary is deliberately this small. Both tags are needed to place a node in the graph and label it; anything more would be a claim the generator cannot check.

2. Every layer gets a diagram

D2, rendered to SVG at build time. 44 diagrams, in three kinds that each bound their node count differently:

  • Overview (1) — the top-level layers, everything below folded into its parent.
  • Neighbourhood (37) — one per layer: what it depends on, what depends on it. Every layer has one, including leaves, which is where readers land.
  • Drill-down (6) — for a layer with sub-layers, its direct children.

Aggregation is honest: an edge appears because imports exist, and counts are real fileDependencies sums. Neighbours are capped at 12 — core.types has 18, so the rest becomes a dashed "+6 further layers" node carrying its 8 dependencies rather than being dropped.

3. Hand-written content merges into the generated tree

content/ is optional (the system works with the directory absent) and carries the reasoning an import graph cannot express. A page names the layer it explains and nests beneath it:

---
title: Memory model
attachTo: core.simulation
---

The layer's page gains a Guides section. Because attachTo decides a page's depth, links are written by name — [text](layer:core.simulation.engine), [text](doc:simulation/memory-model) — and resolved at emit time. Unresolved targets fail the build. Authored pages may import diagram components from the bundle (@diagrams/byte-map).

4. CI enforces the structure

lint:arch-docs fails on: an unannotated source file, a layer id implying an undeclared ancestor, a duplicate declaration, a malformed tag, an attachTo or link target that does not resolve, a configured package whose language has no extractor, and any dependency violating a rule in architecture.config.ts.

Every check is a statement about the graph, which is the whole of what the docs assert. Four rules are enforced; the substantive one — react must not depend on ui — already held (0 imports against 235 the other way), so it locks in an existing property.

5. What this replaces

  • petrinaut-core/scripts/generate-dependency-diagrams.mjs 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 7 of petrinaut-core's 10 entry points, so imports through ./ai, ./optimization and ./compiled-model were absent from the diagrams entirely.
  • The 3,100 lines of hand-written HTML in 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

  • FE-1322 (this PR)
  • FE-1157 — moving Petrinaut docs to hash.dev/docs/petrinaut. This produces the exportable artefact that work needs; it publishes nothing itself.
  • FE-1139 — the ticket that produced the HTML pages migrated here.

Pre-Merge Checklist 🚀

🚢 Has this modified a publishable library?

  • does not modify any publishable blocks or libraries, or modifications do not need publishing

@hashintel/petrinaut and petrinaut-core are touched only by comments, READMEs, and removal of a private script and its dependency-cruiser devDependency. No runtime code, types or exports change.

📜 Does this require a change to the docs?

  • require changes to docs which are made as part of this PR

The user-facing guide (libs/@hashintel/petrinaut/docs/) is untouched — no UI or behaviour changed. AGENTS.md gains a section on declaring layers and what CI enforces.

🕸️ Does this require a change to the Turbo Graph?

  • affected the execution graph, and the turbo.json's have been updated to reflect this

The docs build lives entirely in the task graph — no repo-root entry point, nothing shelling out across packages:

@apps/petrinaut-docs#{build,lint:tsc,dev} → #sync:bundle → @local/petrinaut-arch-docs#doc:architecture

doc:architecture is deliberately not cached: Turborepo hashes a package plus its dependencies' task outputs, and the annotations this reads are source comments in petrinaut and petrinaut-core — nobody's output. A cached bundle would survive an annotation change and go quietly stale, which is the rot this package exists to catch. It declares outputs, so consumers depend on the task rather than on the directory existing.

Removes doc:dependency-diagram from petrinaut-core and the repo-root mise run doc:architecture task.

⚠️ Known issues

  • The taxonomy is a tree, so cross-cutting concerns have no home. Every layer has exactly one parent. core.types is the symptom already in the model: four distinct parents across two packages depend on it, so it is filed under core while behaving like a shared foundation under everything.
  • Nothing verifies @role. The structure is checked against the import graph; the one-line description of each layer is prose. Declaring it beside the code makes it likelier to be corrected when that code changes, but CI does not hold it to account.
  • Richer annotations are written but unread. @boundary, @invariant, @entryPoint and @layerName remain in the Petrinaut source and are ignored by this version. Unknown tags are skipped by design and README frontmatter is tolerant, so re-reading one is a schema change in the generator, not a rewrite of the packages. Layer names are meanwhile derived from the id's last segment, so a few read plainly (ui renders as "Ui").
  • Three packages are not covered. petrinaut-cli, petrinaut-website and petrinaut-opt have no declarations. A TypeScript package is a config entry plus one root declaration; the Python app needs docstring extraction, which is not written — configuring a package for a language with no extractor is a hard error rather than a silent no-op.
  • Hosting the bundle asks one thing: a React-capable MDX pipeline, for the authored pages that import diagram components. Nothing hydrates, so no client-side runtime is required. Generated pages are plain CommonMark, diagrams are static SVG, and architecture.md has no components at all.
  • The docs site is local-only. Nothing deploys it yet — see next steps.
  • mise run fix:package-json could not run locally (needs a nightly Cargo feature), so package.json key ordering was verified by reading the sorter's field list.

🐾 Next steps

  • Decide where the site is deployed. demo.petrinaut.org/docs is feasible — the SPA has no catch-all rewrite, and an Astro base: "/docs" build was tested — but it needs d2 added to the Vercel install step, and is worth settling against FE-1157 first so there is one canonical URL.
  • Cover the remaining three packages.

🛡 What tests cover this?

48 tests in @local/petrinaut-arch-docs:

  • tags.test.ts — tag grammar: multi-line continuation, duplicates, typo suggestions, that a tag named in prose is not a declaration, and that an unread annotation is ignored rather than rejected.
  • frontmatter.test.ts — declarations, malformed YAML, half-written declarations, CRLF, unknown keys ignored.
  • extract.test.ts — inheritance through undeclared folders, 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?

turbo run dev --filter @apps/petrinaut-docs   # http://localhost:4321

Open Architecture for the overview, then any layer — including a leaf such as core.clipboard — and confirm it opens with a diagram of what it depends on and what depends on it. core.types exercises the neighbour cap: 12 drawn, plus a dashed "+6 further layers" node.

To watch the task graph do its job, delete the bundle first — the build regenerates it, syncs it, then renders, as three ordered tasks:

rm -rf libs/@local/petrinaut-arch-docs/bundle
turbo run build --filter @apps/petrinaut-docs

Confirm the checks hold, and hold in either order — formatting and generation both used to claim the bundle's files:

yarn fix:format && yarn workspace @local/petrinaut-arch-docs doc:architecture
yarn lint:format                                          # expect 0
yarn workspace @local/petrinaut-arch-docs lint:arch-docs  # expect 0

Then break something and confirm it is caught: change a role: in any layer-declaring README, point an attachTo at a layer that does not exist, or reference layer:core.nonexistent — each fails lint:arch-docs with the offending file named.

For the AI-facing side, read libs/@local/petrinaut-arch-docs/bundle/architecture.md — the whole architecture in one file.

kube added 4 commits August 5, 2026 17:40
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.
@kube kube self-assigned this Aug 5, 2026
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

3 Skipped Deployments
Project Deployment Actions Updated (UTC)
hash Ignored Ignored Preview Aug 11, 2026 11:55pm
hashdotdesign-tokens Ignored Ignored Preview Aug 11, 2026 11:55pm
petrinaut Skipped Skipped Aug 11, 2026 11:55pm

@github-actions github-actions Bot added area/deps Relates to third-party dependencies (area) area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team type/eng > backend Owned by the @backend team area/apps type/legal Owned by the @legal team labels Aug 5, 2026
export const GENERATED_ORDER_BASE = 1000;

const escapeTableCell = (text: string): string =>
text.replace(/\|/gu, "\\|").replace(/\n/gu, " ");
Comment thread libs/@local/petrinaut-arch-docs/src/tags.ts Fixed

const fields = match[1] ?? "";
const read = (key: string): string | null => {
const found = new RegExp(`^${key}\\s*:\\s*(.+)$`, "mu").exec(fields);
@codspeed-hq

codspeed-hq Bot commented Aug 5, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 15.38%

❌ 2 regressed benchmarks
✅ 96 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

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 (0ab1c8f) with main (271284a)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (77fb79c) during the generation of this report, so 271284a was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

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.
kube added 2 commits August 5, 2026 23:56
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.
@kube
kube temporarily deployed to pull-request August 7, 2026 00:19 — with GitHub Actions Inactive
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Dependency Review

The following issues were found:

  • ❌ 1 vulnerable package(s)
  • ✅ 0 package(s) with incompatible licenses
  • ✅ 0 package(s) with invalid SPDX license definitions
  • ⚠️ 1 package(s) with unknown licenses.
  • ⚠️ 13 packages with OpenSSF Scorecard issues.

View full job summary

Nothing off the shelf does this. Every mature architecture-docs tool —
Structurizr, LikeC4, the C4 tooling generally — implements drill-down as
view switching rather than in-place folding, and the two libraries with
first-party collapse-with-aggregation are both ruled out: cytoscape's
expand-collapse plugin declares itself unmaintained, and G6 renders to
canvas, which costs the real `<a href>` into each layer's page that is the
whole point here.

So the renderer is reused and the domain logic is ours. `@xyflow/react` is
already this repo's canvas — Petrinaut's own Petri net editor runs on it,
and hash-frontend pins the same 12.10.1 — and `elkjs` is already a
dependency of petrinaut-core. Zero new vendors.

The split that makes it work: nothing computes a layout in the browser.
Folding a layer makes its descendants' own fold states unobservable, so the
reachable states are enumerable — 30, not 2^6 — and the build lays out every
one with ELK and ships the coordinates. That keeps elkjs a devDependency
that is never distributed, which matters because it is EPL-2.0 rather than
MIT/Apache, and it drops 1.6 MB of elk-worker from the client.

It also renders without JavaScript. The server, and the first client render,
emit a plain inline `<svg>` from the same coordinates, with a real anchor per
layer and ELK's routed edges; only after mount does React Flow take over for
pan, zoom and the fold controls. A host that never hydrates the island still
gets a correct, navigable diagram — which is what makes the new dependency
acceptable in a bundle that has to embed elsewhere.

`src/emit/collapse.ts` holds the re-pointing as pure functions with 14 tests.
Two cases stop being drawable when folded and are reported on the node as
internal instead: an edge between two layers folded into the same box, and an
edge between a layer and something nested inside it. 69 of the 177 edges are
the latter, so drawing them would mean 69 arrows pointing into their own box.
Reciprocal pairs — 33 of them — merge into one edge keeping both counts.
Nothing here invents a dependency: aggregated edges sum real fileDependencies
and internal counts report real imports.

One fix worth knowing about, because it fails silently and confusingly:
React Flow gives its per-edge `<svg>` no size, relying on the SVG default of
`display: inline` plus `overflow: visible`. Starlight's reset says
`svg { display: block }`, which makes it `width: auto` inside a zero-width
parent — and an SVG of zero width is not rendered at all, while still
reporting a correct bounding box to script. Every edge disappeared. The CSS
now pins those dimensions so the diagram does not depend on the host's reset.

Payload: 289 KB of coordinates, 13 KB gzipped.

Verified in the browser: server render carries the static SVG with 6 routed
edges and four `/architecture/*.html` anchors; after hydration, folding
`core` open shows its 15 sub-layers nested in a container with 32 edges
re-routed; no console errors. 72 tests, tsc, oxlint and the architecture
check all pass, and the site builds 47 pages.
@vercel
vercel Bot temporarily deployed to Preview – petrinaut August 7, 2026 03:17 Inactive
@kube
kube temporarily deployed to pull-request August 7, 2026 03:18 — with GitHub Actions Inactive
@kube
kube temporarily deployed to pull-request August 7, 2026 03:18 — with GitHub Actions Inactive
The README claimed a React-capable MDX pipeline was "the one thing the
bundle asks of a host". That stopped being true in the previous commit,
which added a layer map needing `@xyflow/react` and a hydration directive —
and said so twenty lines further down, contradicting the earlier sentence.

Replaced with the full list, and a statement of what is deliberately absent:
the bundle asks for no Markdown or Rehype plugins, so a host renders it with
its pipeline exactly as configured. That is a real constraint rather than an
accident — anything requiring a plugin would turn "render the bundle" from a
small job into a negotiation with every host.
@vercel
vercel Bot temporarily deployed to Preview – petrinaut August 8, 2026 16:43 Inactive
@kube
kube temporarily deployed to pull-request August 8, 2026 16:43 — with GitHub Actions Inactive
@kube
kube temporarily deployed to pull-request August 8, 2026 16:43 — with GitHub Actions Inactive
Generating the architecture bundle was reachable two ways, and neither
went through Turborepo. `mise run doc:architecture` made it a repo-root
task, and `sync-bundle.mjs` spawned the generator itself with `spawnSync`
— a cross-package build step hidden inside a shell call, so Turborepo
could not order it, report on it, or know it had happened.

Both are gone. `@apps/petrinaut-docs#sync:bundle` now declares
`@local/petrinaut-arch-docs#doc:architecture` as a dependency and the
script only copies, so the whole chain resolves in the graph:

  build → sync:bundle → doc:architecture

`dev` joins `build` and `lint:tsc` in depending on the one shared
`sync:bundle`, rather than chaining it in its package script. Running the
sync without a bundle now exits 1 with the Turborepo command to use,
instead of failing on a missing directory partway through a copy.

`doc:architecture` stays uncached and says why: Turborepo hashes a
package plus its dependencies' task *outputs*, and the annotations this
reads are source comments in petrinaut and petrinaut-core, which are
nobody's output. A cached result would survive an annotation change and
go quietly stale — the exact rot this package exists to catch. It
declares `outputs` so consumers can depend on the task rather than on the
directory existing.

Also removes `.claude/launch.json`, and two things the branch had left
stale: the CI step's comment still said `@seam`, and the app README still
claimed it had no `lint:tsc`.
Comment thread yarn.lock
@@ -30306,7 +32036,7 @@ __metadata:
languageName: node
linkType: hard

"js-yaml@npm:4.3.0, js-yaml@npm:^4.1.0, js-yaml@npm:^4.1.1":
"js-yaml@npm:4.3.0, js-yaml@npm:^4.1.0, js-yaml@npm:^4.1.1, js-yaml@npm:^4.3.0":

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High severity vulnerability may affect your project—review required:
Line 32039 lists a dependency (js-yaml) with a known High severity vulnerability.

ℹ️ Why this matters

Affected versions of js-yaml are vulnerable to Inefficient Algorithmic Complexity. An attacker can supply a YAML document containing a large !!omap sequence, which js-yaml resolves with a linear duplicate-key scan inside its per-element loop. Resolution is therefore quadratic in the number of entries, so a modestly sized document consumes disproportionate CPU inside the load call and blocks the event loop, resulting in a denial of service.

References: GHSA

To resolve this comment:
Check if you are using js-yaml on the CLI.

  • If you're affected, upgrade this dependency to at least version 4.3.1 at yarn.lock.
  • If you're not affected, comment /fp we don't use this [condition]
💬 Ignore this finding

To ignore this, reply with:

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

You can view more details on this finding in the Semgrep AppSec Platform here.

let parsed: unknown;

try {
parsed = load(match[1] ?? "");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High severity and reachable issue identified in your code:
Line 75 has a vulnerable usage of js-yaml, introducing a high severity vulnerability.

ℹ️ Why this is reachable

A reachable issue is a real security risk because your project actually executes the vulnerable code. This issue is reachable because your code uses a certain version of js-yaml.
Affected versions of js-yaml are vulnerable to Inefficient Algorithmic Complexity. An attacker can supply a YAML document containing a large !!omap sequence, which js-yaml resolves with a linear duplicate-key scan inside its per-element loop. Resolution is therefore quadratic in the number of entries, so a modestly sized document consumes disproportionate CPU inside the load call and blocks the event loop, resulting in a denial of service.

References: GHSA

To resolve this comment:
Upgrade this dependency to at least version 4.3.1 at yarn.lock.

💬 Ignore this finding

To ignore this, reply with:

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

You can view more details on this finding in the Semgrep AppSec Platform here.

Nothing inside a node responded to a real mouse click — not the fold
buttons, not the layer links. React Flow sets `pointer-events: none` on a
node that is not selectable, not draggable and not connectable, and this
diagram is configured as all three: dragging a layer box would imply the
arrangement means something the reader chose, and it does not.

Its interaction model assumes a node is interactive *because* you select,
drag or connect it, so a read-only node that merely contains links has no
supported shape. Restoring pointer events on the node re-enables its
contents without re-enabling dragging. `!important` is needed because the
value is written as an inline style, which no stylesheet rule can outrank.

My earlier verification of this was wrong, not merely incomplete: it
called `element.click()` from script, which dispatches straight to the
handler and bypasses hit-testing entirely — so it passed against a node
that no pointer could ever reach. Re-checked with a real click through the
browser, and by hit-testing each button's centre with `elementFromPoint`
before clicking.
@vercel
vercel Bot temporarily deployed to Preview – petrinaut August 10, 2026 23:39 Inactive
The map is read-only: there is nothing in it to select, drag or connect,
only things to read and click through to. React Flow is a node-editor, and
using it as a viewer cost more than it gave.

It had already produced one silent failure — a node that is not selectable,
draggable or connectable is marked `pointer-events: none`, which is exactly
how a read-only diagram is configured, so nothing inside any node could be
clicked. By the end it needed four CSS overrides, one of them `!important`,
purely to un-editor it. And it meant two renderers for one picture: a
server-rendered SVG and a React Flow tree that had to agree, and did not.

We already owned both hard parts. ELK computes every fold state's layout at
build time, and the SSR renderer already drew it as SVG with real anchors
and routed edges. React Flow was supplying pan, zoom and the fold buttons —
and its pan/zoom *is* d3-zoom, which is now used directly.

So there is one renderer. The markup the server produces is the markup the
browser produces, plus a zoom transform and working controls. Fold buttons
are our own SVG, so nothing can intercept them. `@xyflow/react` leaves the
bundle's dependency surface; `d3-zoom` and `d3-selection` replace it.

Layout now emits routed edge points for all 30 states, not just the one the
server rendered — the previous version could rely on React Flow routing the
rest. That is most of the payload: 34 KB gzipped of the 50 KB island, with
the code itself around 16 KB.

Verified by clicking with a real mouse, after hit-testing each control with
`elementFromPoint`: 4 nodes to 19 on expanding core, 32 edges re-routed,
wheel and drag both moving the transform. The no-JavaScript render is
unchanged — 4 boxes, 6 routed edges, 4 layer links.

Also fixes pointers left stale by the previous commit: AGENTS.md and three
package docs still named `mise run doc:architecture` or the yarn dev script
that now skips the task graph. My earlier sweep for these reported none,
wrongly — `grep "a\|b"` under this system's grep matches the literal string
rather than either alternative, so it searched for something impossible.
kube added 2 commits August 12, 2026 01:04
`extract` skipped any package whose language was not TypeScript with a bare
`continue`, and said nothing. The package still reached the model through
`build.ts`, so a Python entry produced a package listed as covered, zero
layers, zero files, and a clean CI run.

`checkEmptyLayers` cannot catch this: it reports layers that exist and hold
no files, and a package with no declarations produces no layers at all.

That is the same failure as the path-prefix map this package replaced — a
silent fallback that quietly mis-describes what it covers — rebuilt inside
the thing meant to remove it. It is now an error naming the package and
saying to remove it from the config until an extractor exists.
The interactive map answered "what is the shape of the whole system", which
is a question you ask once. The question a reader actually arrives with is
"what does this layer touch", and the map was the only place to ask it — the
static diagrams existed solely for layers *with* sub-layers, so a leaf, which
is where people land, had no picture at all.

Every layer now gets a neighbourhood diagram: what it depends on, what
depends on it, with the focus outlined and only edges incident to it drawn.
Edges among the neighbours are real but belong to those layers' own pages;
including them rebuilds the tangle the overview already avoids. Layers with
sub-layers keep their drill-down, so a parent answers both questions.

That is 44 diagrams — 1 overview, 37 neighbourhoods, 6 drill-downs — against
6 before. They are per-page images fetched on demand rather than one module
loaded on the home page, which is what the map cost: 440 KB of pre-computed
ELK coordinates for all 30 fold states, more than twice the entire model.

Neighbour count is bounded at 12. `core.types` has 18, and the remainder is
drawn as a dashed "+6 further layers" node carrying its 8 file-level
dependencies, in whichever directions those layers actually use. Elided
where it would be unreadable, never dropped where it would read as absent.

Removed with the map: `emit/layout.ts`, `emit/collapse.ts`, the graph
component and its stylesheet — 1,515 lines — plus `elkjs`, `d3-zoom` and
`d3-selection`. No layout runs anywhere but `d2` now.

Diagram names are namespaced by directory (`around/`, `within/`) rather than
by a name prefix: a layer id is unique only among layer ids, so a flat
`around-<id>` would collide with a top-level layer named `around-something`.

Also clears `components/` between builds. Only `pages/` and `diagrams/` were
rewritten wholesale, so the deleted component survived the first rebuild as
an orphan — the same stale-artefact problem that comment already describes,
in the one directory it did not cover.
@vercel
vercel Bot temporarily deployed to Preview – petrinaut August 11, 2026 23:06 Inactive
Seven tags, of which one was checked. `@role`, `@boundary` and `@invariant`
were prose the generator could not verify, so "CI fails when a declaration
stops being true" held for about a third of what you could declare — and the
gap was invisible from the outside.

This version claims less and means it. Two tags remain: `@layerRoot` names
the layer a folder and its descendants form, `@role` says what it is for.
Between them they place a node in the graph and label it, which is the whole
of what the docs assert. Every surviving check is a statement about the graph
— an unannotated file, an ancestor nobody declared, a rule the real import
graph violates — and none of them rests on prose.

`@layerName`, `@boundary`, `@invariant` and `@entryPoint` are no longer read.
`@layer` is deleted outright: it was used zero times, so it had never been
exercised against `core.types`, the one case it exists for.

The 58 annotations already written stay exactly where they are. Unknown tags
are ignored by design, so nothing had to be stripped out of the Petrinaut
source to satisfy a generator that has simply stopped looking — and README
frontmatter is tolerant rather than strict for the same reason, since
rejecting `boundaries:` would have forced those facts to be deleted. Reading
one again is a schema change here, not a rewrite of the packages.

`@entryPoint` is the one that cost something real: it was the only annotation
checkable against evidence the docs did not control, validated against each
package's `exports`. Nothing it did was structural — `graph.ts` never read it,
and the 177 edges resolve through `deriveAliases` — so the graph is
unchanged, but CI no longer holds any documentation claim to account.

Pass 2 of the extractor no longer scans tags at all. With no per-file
annotations left, a file's contents say nothing about the architecture beyond
which folder it sits in, so it is read only for its line count.

Also folds in the tidying this left obvious: `toPosix` was copied verbatim
into three modules and is now one; `slugForLayer` was a pass-through alias
over `layerSlug` in the same file; the authored-page slugs, guide grouping
and link resolution walked the same list three times with the `attachTo`
validity check duplicated across two of them, and now walk it once; and
`bundleTextFiles` stripped a `components/` prefix only to re-add it, which
was papering over an inconsistency that left with the generated layouts.
@vercel
vercel Bot temporarily deployed to Preview – petrinaut August 11, 2026 23:55 Inactive
@kube

kube commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

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 drift binds a markdown file to a source file or AST symbol, so you can use drift check to trace when your doc goes (potentially) stale.

The docs stay pure markdown(bindings live in drift.lock, not in frontmatter) so it doesn't collide with attachTo or the emit pipeline.

It might also be a good answer to the petrinaut-opt item under Known Issues, since drift parses Python natively

Adoption, roughly, according to claude:

  • Bind each content/**.mdx page to the handful of symbols it actually describes, e.g. drift link libs/@local/petrinaut-arch-docs/content/simulation/memory-model.mdx <frame buffer allocation site>#<symbol>. Roughly 15–20 bindings total.
  • Add drift check --changed to the existing lint:arch-docs CI job; it exits 1 on stale.
  • Skip its broken-link checking — emit/mdx.ts already resolves links, and doubling up would just produce two error formats for one problem.
  • Don't let bindings substitute for the generator's coverage check; drift is blind to any file nobody bound, so the two checks are doing different jobs.

Thanks @lunelson, it actually seems to be a good thing to add on top.

For this first version I'll keep things simple so we can already provide some value with docs, see graph of dependencies etc...

But definitely a good tool to add in a next step.

@kube

kube commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

@kube kube closed this Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/apps area/deps Relates to third-party dependencies (area) area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > backend Owned by the @backend team type/eng > frontend Owned by the @frontend team type/legal Owned by the @legal team

Development

Successfully merging this pull request may close these issues.

4 participants