Skip to content

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

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

FE-1322: Generate Petrinaut architecture docs from in-code annotations#9165
kube wants to merge 16 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 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
Loading

🔍 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.md frontmatter 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:

---
layer: core.simulation.monte-carlo
role: Runs many simulations with bounded frame memory
seams: ["@hashintel/petrinaut-core/workers/monte-carlo"]
boundaries:
  - kind: worker
    note: Frame buffers stay inside the worker
---

Doc-comment tags attach facts to the specific code that upholds them, picked up from any block comment in any file:

/**
 * @layerRoot core.lsp            declares a layer from an entry file
 * @role Language-server client for editing user code
 * @boundary thread — requests reach the server over a worker transport
 * @invariant Two reusable frame buffers per run; no per-frame allocation
 * @seam @hashintel/petrinaut-core/workers/lsp
 */

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:

---
title: Memory model
attachTo: core.simulation   # a layer declared in the source
---

The layer's page gains a Guides section, and the sidebar nests the guide beside that layer's sub-layers.

Because attachTo decides 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-docs fails on: an unannotated source file, a layer id implying an undeclared ancestor, a duplicate declaration, a malformed tag, a @seam that is no longer a real export, an attachTo or link target that does not resolve, and any dependency violating a rule in architecture.config.ts.

Three rules are enforced today. The substantive one — react must not depend on ui — 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.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. Entry points now derive from each package's exports.
  • 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. Happy to add a patch changeset if reviewers would rather the README changes ship.

📜 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

Adds doc:architecture / lint:arch-docs on the generator, and sync:bundlebuild / lint:tsc on the docs app. Removes doc:dependency-diagram from petrinaut-core.

⚠️ Known issues

  • 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.
  • Authored pages need a React-capable MDX pipeline in any host, because of the diagram components. Generated pages stay plain CommonMark, and architecture.md — the artefact for agents — has no components.
  • starlight-llms-txt was dropped, not worked around: it renders MDX in a container with no React renderer, and its exclude option is accepted but never passed to the /llms-full.txt route. The site serves the bundle's own architecture.md and architecture.json instead.
  • 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 it is worth settling against FE-1157 first so there is one canonical URL.
  • Cover the remaining three packages.

🛡 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, @layer overrides, 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:4321

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 7, 2026 3:18am
hashdotdesign-tokens Ignored Ignored Preview Aug 7, 2026 3:18am
petrinaut Skipped Skipped Aug 7, 2026 3:18am

@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%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 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 (000eee7) with main (ea10b14)

Open in CodSpeed

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.
@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 59.57%. Comparing base (ac5ec9c) to head (f7a7839).
⚠️ Report is 8 commits behind head on main.

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           
Flag Coverage Δ
apps.hash-ai-worker-ts 1.99% <ø> (ø)
apps.hash-api 12.56% <ø> (ø)
blockprotocol.type-system 40.84% <ø> (ø)
local.claude-hooks 0.00% <ø> (ø)
local.harpc-client 51.49% <ø> (ø)
local.hash-backend-utils 3.27% <ø> (ø)
local.hash-graph-sdk 10.02% <ø> (ø)
local.hash-isomorphic-utils 12.25% <ø> (ø)
rust.antsi 2.36% <ø> (ø)
rust.error-stack 90.81% <ø> (ø)
rust.harpc-codec 84.70% <ø> (ø)
rust.harpc-net 96.21% <ø> (+0.01%) ⬆️
rust.harpc-tower 67.03% <ø> (ø)
rust.harpc-types 0.00% <ø> (ø)
rust.harpc-wire-protocol 92.23% <ø> (ø)
rust.hash-codec 72.76% <ø> (ø)
rust.hash-graph-api 7.36% <ø> (ø)
rust.hash-graph-authorization 62.59% <ø> (ø)
rust.hash-graph-embeddings 91.88% <ø> (ø)
rust.hash-graph-postgres-store 29.66% <ø> (ø)
rust.hash-graph-store 42.16% <ø> (ø)
rust.hash-graph-temporal-versioning 47.95% <ø> (ø)
rust.hash-graph-types 0.00% <ø> (ø)
rust.hash-graph-validation 84.71% <ø> (ø)
rust.hashql-ast 89.63% <ø> (ø)
rust.hashql-compiletest 28.39% <ø> (ø)
rust.hashql-core 78.98% <ø> (ø)
rust.hashql-diagnostics 72.51% <ø> (ø)
rust.hashql-eval 79.82% <ø> (ø)
rust.hashql-hir 89.09% <ø> (ø)
rust.hashql-mir 87.92% <ø> (ø)
rust.hashql-syntax-jexpr 94.04% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread .claude/launch.json
Comment on lines +1 to +17
{
"version": "0.0.1",
"configurations": [
{
"name": "petrinaut-docs",
"runtimeExecutable": "yarn",
"runtimeArgs": [
"workspace",
"@apps/petrinaut-docs",
"dev",
"--port",
"4321"
],
"port": 4321
}
]
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@TimDiekmann @CiaranMn what do you think of having this .claude/launch.json in the repo?

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Benchmark results

@rust/hash-graph-benches – Integrations

policy_resolution_large

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 2002 $$26.8 \mathrm{ms} \pm 216 \mathrm{μs}\left({\color{lightgreen}-19.654 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$3.42 \mathrm{ms} \pm 21.9 \mathrm{μs}\left({\color{lightgreen}-25.138 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 1002 $$12.1 \mathrm{ms} \pm 78.5 \mathrm{μs}\left({\color{lightgreen}-33.331 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: high, policies: 3314 $$43.0 \mathrm{ms} \pm 440 \mathrm{μs}\left({\color{lightgreen}-12.901 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: low, policies: 1 $$15.8 \mathrm{ms} \pm 141 \mathrm{μs}\left({\color{lightgreen}-16.506 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: medium, policies: 1527 $$26.2 \mathrm{ms} \pm 253 \mathrm{μs}\left({\color{lightgreen}-17.872 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 2078 $$28.0 \mathrm{ms} \pm 180 \mathrm{μs}\left({\color{lightgreen}-24.660 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$3.76 \mathrm{ms} \pm 23.9 \mathrm{μs}\left({\color{lightgreen}-20.418 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 1033 $$13.2 \mathrm{ms} \pm 129 \mathrm{μs}\left({\color{lightgreen}-27.206 \mathrm{\%}}\right) $$ Flame Graph

policy_resolution_medium

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 102 $$3.82 \mathrm{ms} \pm 26.8 \mathrm{μs}\left({\color{lightgreen}-27.572 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$2.98 \mathrm{ms} \pm 15.7 \mathrm{μs}\left({\color{lightgreen}-28.680 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 52 $$3.34 \mathrm{ms} \pm 15.1 \mathrm{μs}\left({\color{lightgreen}-28.019 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: high, policies: 269 $$5.17 \mathrm{ms} \pm 39.2 \mathrm{μs}\left({\color{lightgreen}-21.164 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: low, policies: 1 $$3.54 \mathrm{ms} \pm 18.7 \mathrm{μs}\left({\color{lightgreen}-26.625 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: medium, policies: 108 $$4.12 \mathrm{ms} \pm 22.7 \mathrm{μs}\left({\color{lightgreen}-19.931 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 133 $$4.42 \mathrm{ms} \pm 27.2 \mathrm{μs}\left({\color{lightgreen}-31.531 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$3.44 \mathrm{ms} \pm 16.4 \mathrm{μs}\left({\color{lightgreen}-21.897 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 63 $$4.13 \mathrm{ms} \pm 25.6 \mathrm{μs}\left({\color{lightgreen}-27.472 \mathrm{\%}}\right) $$ Flame Graph

policy_resolution_none

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 2 $$2.76 \mathrm{ms} \pm 20.6 \mathrm{μs}\left({\color{lightgreen}-5.154 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$2.59 \mathrm{ms} \pm 15.0 \mathrm{μs}\left({\color{lightgreen}-26.856 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 2 $$2.74 \mathrm{ms} \pm 14.6 \mathrm{μs}\left({\color{lightgreen}-21.530 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 8 $$3.09 \mathrm{ms} \pm 21.4 \mathrm{μs}\left({\color{lightgreen}-17.048 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$2.83 \mathrm{ms} \pm 16.0 \mathrm{μs}\left({\color{lightgreen}-12.786 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 3 $$3.04 \mathrm{ms} \pm 17.4 \mathrm{μs}\left({\color{lightgreen}-18.622 \mathrm{\%}}\right) $$ Flame Graph

policy_resolution_small

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 52 $$3.06 \mathrm{ms} \pm 22.1 \mathrm{μs}\left({\color{lightgreen}-30.724 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$2.76 \mathrm{ms} \pm 17.4 \mathrm{μs}\left({\color{lightgreen}-30.343 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 26 $$3.01 \mathrm{ms} \pm 18.6 \mathrm{μs}\left({\color{lightgreen}-30.437 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: high, policies: 94 $$3.44 \mathrm{ms} \pm 20.2 \mathrm{μs}\left({\color{lightgreen}-27.941 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: low, policies: 1 $$2.99 \mathrm{ms} \pm 18.8 \mathrm{μs}\left({\color{lightgreen}-28.916 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: medium, policies: 27 $$3.31 \mathrm{ms} \pm 19.3 \mathrm{μs}\left({\color{lightgreen}-25.514 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 66 $$3.50 \mathrm{ms} \pm 22.2 \mathrm{μs}\left({\color{lightgreen}-26.720 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$2.97 \mathrm{ms} \pm 15.6 \mathrm{μs}\left({\color{lightgreen}-23.304 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 29 $$3.33 \mathrm{ms} \pm 16.1 \mathrm{μs}\left({\color{lightgreen}-25.818 \mathrm{\%}}\right) $$ Flame Graph

read_scaling_complete

Function Value Mean Flame graphs
entity_by_id;one_depth 1 entities $$42.3 \mathrm{ms} \pm 292 \mathrm{μs}\left({\color{lightgreen}-21.089 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 10 entities $$33.0 \mathrm{ms} \pm 200 \mathrm{μs}\left({\color{lightgreen}-25.360 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 25 entities $$35.8 \mathrm{ms} \pm 232 \mathrm{μs}\left({\color{lightgreen}-28.408 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 5 entities $$32.8 \mathrm{ms} \pm 280 \mathrm{μs}\left({\color{lightgreen}-6.511 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 50 entities $$41.5 \mathrm{ms} \pm 246 \mathrm{μs}\left({\color{lightgreen}-23.773 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 1 entities $$49.7 \mathrm{ms} \pm 309 \mathrm{μs}\left({\color{lightgreen}-13.855 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 10 entities $$40.2 \mathrm{ms} \pm 222 \mathrm{μs}\left({\color{lightgreen}-20.792 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 25 entities $$90.0 \mathrm{ms} \pm 626 \mathrm{μs}\left({\color{lightgreen}-18.693 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 5 entities $$37.3 \mathrm{ms} \pm 2.01 \mathrm{ms}\left({\color{lightgreen}-13.668 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 50 entities $$275 \mathrm{ms} \pm 1.06 \mathrm{ms}\left({\color{lightgreen}-10.445 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 1 entities $$11.0 \mathrm{ms} \pm 69.6 \mathrm{μs}\left({\color{lightgreen}-19.685 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 10 entities $$11.1 \mathrm{ms} \pm 64.9 \mathrm{μs}\left({\color{lightgreen}-19.824 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 25 entities $$11.1 \mathrm{ms} \pm 67.1 \mathrm{μs}\left({\color{lightgreen}-25.824 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 5 entities $$11.1 \mathrm{ms} \pm 70.7 \mathrm{μs}\left({\color{lightgreen}-21.612 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 50 entities $$11.1 \mathrm{ms} \pm 96.4 \mathrm{μs}\left({\color{lightgreen}-17.491 \mathrm{\%}}\right) $$ Flame Graph

read_scaling_linkless

Function Value Mean Flame graphs
entity_by_id 1 entities $$11.0 \mathrm{ms} \pm 58.8 \mathrm{μs}\left({\color{lightgreen}-20.930 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 10 entities $$11.2 \mathrm{ms} \pm 75.3 \mathrm{μs}\left({\color{gray}-4.887 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 100 entities $$11.2 \mathrm{ms} \pm 82.1 \mathrm{μs}\left({\color{lightgreen}-28.112 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 1000 entities $$11.1 \mathrm{ms} \pm 59.9 \mathrm{μs}\left({\color{lightgreen}-31.846 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 10000 entities $$11.2 \mathrm{ms} \pm 77.3 \mathrm{μs}\left({\color{lightgreen}-34.124 \mathrm{\%}}\right) $$ 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 $$11.4 \mathrm{ms} \pm 62.5 \mathrm{μs}\left({\color{lightgreen}-27.268 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/book/v/1 $$11.4 \mathrm{ms} \pm 62.6 \mathrm{μs}\left({\color{lightgreen}-24.901 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/building/v/1 $$11.4 \mathrm{ms} \pm 58.4 \mathrm{μs}\left({\color{lightgreen}-31.431 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/organization/v/1 $$12.0 \mathrm{ms} \pm 239 \mathrm{μs}\left({\color{lightgreen}-26.822 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/page/v/2 $$11.4 \mathrm{ms} \pm 86.0 \mathrm{μs}\left({\color{lightgreen}-23.572 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/person/v/1 $$11.4 \mathrm{ms} \pm 68.1 \mathrm{μs}\left({\color{lightgreen}-27.191 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/playlist/v/1 $$11.4 \mathrm{ms} \pm 77.6 \mathrm{μs}\left({\color{lightgreen}-29.527 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/song/v/1 $$11.5 \mathrm{ms} \pm 69.8 \mathrm{μs}\left({\color{lightgreen}-27.359 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/uk-address/v/1 $$11.4 \mathrm{ms} \pm 61.2 \mathrm{μs}\left({\color{lightgreen}-29.071 \mathrm{\%}}\right) $$ Flame Graph

representative_read_entity_type

Function Value Mean Flame graphs
get_entity_type_by_id Account ID: bf5a9ef5-dc3b-43cf-a291-6210c0321eba $$8.53 \mathrm{ms} \pm 46.6 \mathrm{μs}\left({\color{lightgreen}-24.874 \mathrm{\%}}\right) $$ Flame Graph

representative_read_multiple_entities

Function Value Mean Flame graphs
entity_by_property traversal_paths=0 0 $$60.3 \mathrm{ms} \pm 513 \mathrm{μs}\left({\color{lightgreen}-23.957 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=255 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true $$112 \mathrm{ms} \pm 646 \mathrm{μs}\left({\color{lightgreen}-17.077 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false $$66.9 \mathrm{ms} \pm 587 \mathrm{μs}\left({\color{lightgreen}-27.689 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true $$76.9 \mathrm{ms} \pm 629 \mathrm{μs}\left({\color{lightgreen}-26.132 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true $$84.9 \mathrm{ms} \pm 437 \mathrm{μs}\left({\color{lightgreen}-15.500 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true $$90.6 \mathrm{ms} \pm 530 \mathrm{μs}\left({\color{lightgreen}-21.603 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=0 0 $$43.0 \mathrm{ms} \pm 318 \mathrm{μs}\left({\color{lightgreen}-21.076 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=255 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true $$70.2 \mathrm{ms} \pm 348 \mathrm{μs}\left({\color{lightgreen}-22.820 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false $$48.6 \mathrm{ms} \pm 310 \mathrm{μs}\left({\color{lightgreen}-15.414 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true $$57.7 \mathrm{ms} \pm 404 \mathrm{μs}\left({\color{lightgreen}-13.971 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true $$59.9 \mathrm{ms} \pm 363 \mathrm{μs}\left({\color{lightgreen}-24.271 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true $$59.9 \mathrm{ms} \pm 505 \mathrm{μs}\left({\color{lightgreen}-18.297 \mathrm{\%}}\right) $$

scenarios

Function Value Mean Flame graphs
full_test query-limited $$119 \mathrm{ms} \pm 643 \mathrm{μs}\left({\color{gray}-2.042 \mathrm{\%}}\right) $$ Flame Graph
full_test query-unlimited $$130 \mathrm{ms} \pm 583 \mathrm{μs}\left({\color{lightgreen}-5.761 \mathrm{\%}}\right) $$ Flame Graph
linked_queries query-limited $$19.7 \mathrm{ms} \pm 108 \mathrm{μs}\left({\color{lightgreen}-17.389 \mathrm{\%}}\right) $$ Flame Graph
linked_queries query-unlimited $$527 \mathrm{ms} \pm 1.35 \mathrm{ms}\left({\color{gray}-0.174 \mathrm{\%}}\right) $$ Flame Graph

@@ -0,0 +1,5 @@
# License

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Half a megabyte!

layer: core.hir
name: HIR compiler
role: Lowers user-authored TypeScript to a source-spanned IR, then typechecks, lints and emits it
seams:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we use a term other than seams? Feels very AI.

@lunelson

lunelson commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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.

Four cuts, none of which changes the model: still 37 layers, 177 edges, 15
boundaries, 25 invariants.

**Dead code.** `read-bundle.ts` was written as "the seam every renderer
goes through" and then never used — the docs site reads the manifest
directly. `buildLayerDiagram` produced `layers.d2`, the all-layers view
that was dropped from the pages for being an unreadable tangle; it was
kept as "a diffable record of the whole graph", but the bundle stopped
being versioned two commits later, so nothing diffed it.

**llms.txt.** It largely restated `architecture.md`, which is the artefact
an agent should actually read. One fewer output, one fewer convention to
keep current.

**Fifteen READMEs.** Most existed only to hold frontmatter above a few
lines of invented prose. Their declarations now sit as `@layerRoot` on the
folder's primary file, which is where a reader looking for the layer would
land anyway. Three remain, in folders where no single file is the obvious
host: `workers/` (three peer entry points), `react/state/` (fifteen peers)
and `ui/views/` (no files at all, only sub-folders). The docs now present
the doc comment as the default and the README as the case for folders with
real prose to carry.

**Comments.** Trimmed the over-explained ones, and corrected three that had
gone stale: `model.ts`, `build.ts` and `cli.ts` all still described `check`
as diffing a committed bundle, which stopped being true when the bundle
became build output.
Three points from Dei's review.

**License.** `@apps/petrinaut-docs` and `@local/petrinaut-arch-docs` were
AGPL-3.0; the rest of the Petrinaut family is dual MIT / Apache-2.0. Both
now carry the same three licence files and the same `(MIT OR Apache-2.0)`
field as `petrinaut`, `petrinaut-core`, `petrinaut-cli` and
`petrinaut-website`. Applied to the generator as well as the site, since
the authored documentation lives there.

**The logo.** 582 KB for a mark rendered at about 2 rem. Resized 512px →
128px (still 3× for the header) and re-encoded: 7 KB, 99% smaller.

**`seam` → `entryPoint`.** The tag, the frontmatter key, the model field,
the check and the page heading. "Entry point" says the same thing in plain
English — these are the import specifiers a layer is reachable through,
and the check validates them against the package's `exports`. Two prose
uses of the word elsewhere are reworded rather than left inconsistent.

The rename also surfaced a real bug the tests caught: the parent-id
derivation was reading a layer's display name instead of its dotted id, so
no layer had a parent. Fixed, and the sorted-ordering test that pinned it
now passes.
Two problems a review of the PR diff turned up, both mine.

`.mut/` — 18 files, 3,792 lines duplicating `src/` — was a scratch copy
from a mutation-testing run that a `git add -A` swept into the previous
commit. It was 28% of the PR's insertions and showed every generator
module to a reviewer twice. Deleted, and the directory is now ignored so
it cannot recur.

`src/graph.ts` held two literal NUL bytes where `emit/d2.ts` correctly
writes the escape sequence as source text. They came from a patch script
that put the escape inside a JS string literal, so it was interpreted
rather than written. Git classified the file as binary: it rendered as
`Bin 0 -> 7189 bytes` in the PR, making the 242-line core of the import
graph unreviewable, and grep skipped it silently — which is why an earlier
search of that file returned nothing and looked like a tooling glitch.
Behaviour is unchanged: the separator is the same character, only its
spelling in source differs.
@vercel
vercel Bot temporarily deployed to Preview – petrinaut August 6, 2026 16:08 Inactive
The previous commit claimed this: 512px/582 KB down to 128px/7 KB. The
resize ran and was measured, but the file was restored to its original
size before the commit was staged, so the 582 KB asset is what shipped.
Redone, and verified against the staged blob rather than the working
copy — `git cat-file -s` on the index entry reports 7,064 bytes.

Rendered header markup is unchanged apart from the intrinsic size
(`width="128" height="128"`), and the site still builds 47 pages.
@vercel
vercel Bot temporarily deployed to Preview – petrinaut August 6, 2026 16:09 Inactive
Everything removed here was built for a use that never arrived, and each
piece cost a reader something.

`owner` was plumbed through six files and populated zero times: no source
declares `@owner` or a frontmatter `owner`, so both render sites were dead
branches. CODEOWNERS is the enforced home for that fact anyway, and a
decorative copy in doc comments can go stale with nothing to catch it.

`ignoredTagNames` listed 30 JSDoc tags to suppress, but the diagnostic it
guarded only fires when an unknown tag case-insensitively matches one of
ours — which none of those 30 ever could. The set documented an intent the
code did not implement. The check now says what it does: report a miscased
version of our own tag, ignore everything else.

Also gone: `manifest.diagrams` and `DiagramRecord` (diagrams are addressed
by path, never through the manifest), `ManifestPage.depth` (derivable from
the slug, and the sidebar builder derives it), `TagScanResult.annotated`,
`GraphResult.unresolvedCount` (counted node_modules imports, so it could
never be a useful signal), an unused `Diagram` interface, five `export
default`s on components every page imports by name, and a
`content.config.mjs` schema extension for frontmatter the sidebar reads
from `manifest.json` instead.

The README now leads with the whole minimum — `@layerRoot` plus `@role` —
and puts the optional tags in a table, rather than opening with a
frontmatter block carrying every field at once.

Verified: 58 tests pass, the check reports the same 37 layers / 412 files
/ 177 edges, and the site builds 47 pages with all five diagram components
rendering.
@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
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