FE-1322: Generate Petrinaut architecture docs from in-code annotations - #9204
FE-1322: Generate Petrinaut architecture docs from in-code annotations#9204kube wants to merge 1 commit into
Conversation
The architecture was described in a script that sat nowhere near the code it described: ~180 lines of `if (path.startsWith(...))` in `generate-dependency-diagrams.mjs`, 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. This replaces it with declarations that live beside the code, and a generator that joins them with the real import graph. A declaration is two tags. `@layerRoot <id>` names the layer a folder and its descendants form; `@role <one line>` says what it is for. A folder README's frontmatter declares the same pair, and its prose becomes that layer's page. Files with no annotation inherit from the nearest declaring ancestor, which is what keeps this proportional to the architecture rather than the file count: 37 declarations cover 412 files, producing 37 layers and 177 edges. The vocabulary stops there deliberately. Both tags are needed to place a node in the graph and label it, which is the whole of what these docs assert. Anything further would be prose the generator cannot check, and a docs system that cannot check its own claims is the thing being replaced. Output is a portable bundle, not a website: `architecture.json` for consumers, `architecture.md` for a single-pass read, generated pages, and 44 D2 diagrams — an overview, a neighbourhood per layer showing what it depends on and what depends on it, and a drill-down for each layer with children. Leaves get a diagram too; they are where readers land. `bundle/` is git-ignored build output. Committing it would mean reviewing every change twice and resolving conflicts in generated files, and a stored copy could go stale against the annotations that produced it. CI runs `lint:arch-docs`, which fails on an unannotated source file, a layer id implying an ancestor nobody declared, a duplicate declaration, a malformed tag, a package configured for a language with no extractor, and any dependency violating a rule in `architecture.config.ts`. Every check is a statement about the graph. 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 a property the code already has. `doc:architecture` is deliberately uncached: 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 bundle would survive an annotation change and go quietly stale. The authored-content pipeline is here and exercised by tests, but this branch ships no `content/` directory and no renderer; both follow separately.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Dependency ReviewThe following issues were found:
Vulnerabilitieslibs/@local/petrinaut-arch-docs/package.json
License Issuesyarn.lock
OpenSSF ScorecardScorecard details
Scanned Files
|
| export const GENERATED_ORDER_BASE = 1000; | ||
|
|
||
| const escapeTableCell = (text: string): string => | ||
| text.replace(/\|/gu, "\\|").replace(/\n/gu, " "); |
| let parsed: unknown; | ||
|
|
||
| try { | ||
| parsed = load(match[1] ?? ""); |
There was a problem hiding this comment.
High severity and reachable issue identified in your code:
Line 64 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.
| let parsed: unknown; | ||
|
|
||
| try { | ||
| parsed = load(match[1] ?? ""); |
| let parsed: unknown; | ||
|
|
||
| try { | ||
| parsed = load(match[1] ?? ""); |
|
|
||
| const fields = match[1] ?? ""; | ||
| const read = (key: string): string | null => { | ||
| const found = new RegExp(`^${key}\\s*:\\s*(.+)$`, "mu").exec(fields); |
Benchmark results
|
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 2002 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 1002 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: high, policies: 3314 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: medium, policies: 1527 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 2078 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 1033 | Flame Graph |
policy_resolution_medium
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 102 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 52 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: high, policies: 269 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: medium, policies: 108 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 133 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 63 | Flame Graph |
policy_resolution_none
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 2 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 2 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 8 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 3 | Flame Graph |
policy_resolution_small
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 52 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 26 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: high, policies: 94 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: medium, policies: 27 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 66 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 29 | Flame Graph |
read_scaling_complete
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_id;one_depth | 1 entities | Flame Graph | |
| entity_by_id;one_depth | 10 entities | Flame Graph | |
| entity_by_id;one_depth | 25 entities | Flame Graph | |
| entity_by_id;one_depth | 5 entities | Flame Graph | |
| entity_by_id;one_depth | 50 entities | Flame Graph | |
| entity_by_id;two_depth | 1 entities | Flame Graph | |
| entity_by_id;two_depth | 10 entities | Flame Graph | |
| entity_by_id;two_depth | 25 entities | Flame Graph | |
| entity_by_id;two_depth | 5 entities | Flame Graph | |
| entity_by_id;two_depth | 50 entities | Flame Graph | |
| entity_by_id;zero_depth | 1 entities | Flame Graph | |
| entity_by_id;zero_depth | 10 entities | Flame Graph | |
| entity_by_id;zero_depth | 25 entities | Flame Graph | |
| entity_by_id;zero_depth | 5 entities | Flame Graph | |
| entity_by_id;zero_depth | 50 entities | Flame Graph |
read_scaling_linkless
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_id | 1 entities | Flame Graph | |
| entity_by_id | 10 entities | Flame Graph | |
| entity_by_id | 100 entities | Flame Graph | |
| entity_by_id | 1000 entities | Flame Graph | |
| entity_by_id | 10000 entities | Flame Graph |
representative_read_entity
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/block/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/book/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/building/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/organization/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/page/v/2
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/person/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/playlist/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/song/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/uk-address/v/1
|
Flame Graph |
representative_read_entity_type
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| get_entity_type_by_id | Account ID: bf5a9ef5-dc3b-43cf-a291-6210c0321eba
|
Flame Graph |
representative_read_multiple_entities
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_property | traversal_paths=0 | 0 | |
| entity_by_property | traversal_paths=255 | 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true | |
| link_by_source_by_property | traversal_paths=0 | 0 | |
| link_by_source_by_property | traversal_paths=255 | 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true |
scenarios
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| full_test | query-limited | Flame Graph | |
| full_test | query-unlimited | Flame Graph | |
| linked_queries | query-limited | Flame Graph | |
| linked_queries | query-unlimited | Flame Graph |
🌟 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.First of three. This PR is the generator and the annotations. #9205 migrates the hand-written architecture prose into it; #9206 adds a site that renders it. Each is independently correct; this one is the only one that needs reviewing to judge whether the architecture model is right.
🔍 What does this change?
1. Two annotations describe the whole architecture
A folder
README.mddeclares the same pair in frontmatter, and its prose becomes that layer's page.Files with no annotation inherit from the nearest declaring ancestor, which keeps this proportional to the architecture rather than the file count: 37 declarations cover 412 files. Layer sizes, the 177 dependency edges and the parent/child tree are all derived from the real TypeScript import graph.
The vocabulary stops there deliberately. Both tags are needed to place a node in the graph and label it, which is the whole of what these docs assert. Anything further would be prose the generator cannot check — and a docs system that cannot check its own claims is the thing being replaced.
2. Every layer gets a diagram
D2, rendered to SVG at build time. 44 diagrams in three kinds, each bounding node count differently:
Aggregation never invents a dependency: edges exist because imports exist, and counts sum real
fileDependencies. Neighbours are capped at twelve —core.typeshas 18, so the rest becomes a dashed "+6 further layers" node carrying its 8 dependencies rather than being dropped.3. CI enforces the structure
lint:arch-docsfails on: an unannotated source file, a layer id implying an undeclared ancestor, a duplicate declaration, a malformed tag, a package configured for a language with no extractor, and any dependency violating a rule inarchitecture.config.ts.Every check is a statement about the graph. Four rules are enforced; the substantive one —
reactmust not depend onui— already held (0 imports against 235 the other way), so it locks in an existing property.4. What this replaces
petrinaut-core/scripts/generate-dependency-diagrams.mjsheld the architecture as ~180 lines ofif (path.startsWith(...))far from the code, with a fallback that silently mis-bucketed anything renamed. It hard-coded 7 ofpetrinaut-core's 10 entry points, so imports through./ai,./optimizationand./compiled-modelwere absent from the diagrams entirely.🔗 Related links
hash.dev/docs/petrinaut. This produces the exportable artefact that work needs; it publishes nothing itself.Pre-Merge Checklist 🚀
🚢 Has this modified a publishable library?
@hashintel/petrinautandpetrinaut-coreare touched only by comments, READMEs, and removal of a private script and itsdependency-cruiserdevDependency. No runtime code, types or exports change.📜 Does this require a change to the docs?
The user-facing guide (
libs/@hashintel/petrinaut/docs/) is untouched — no UI or behaviour changed.AGENTS.mdgains a section on declaring layers and what CI enforces.🕸️ Does this require a change to the Turbo Graph?
turbo.json's have been updated to reflect thisAdds
@local/petrinaut-arch-docs#doc:architecture, deliberately not cached: Turborepo hashes a package plus its dependencies' task outputs, and the annotations this reads are source comments inpetrinautandpetrinaut-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 declaresoutputs, so consumers depend on the task rather than on the directory existing.Removes
doc:dependency-diagramfrompetrinaut-core.core.typesis the symptom already in the model: four distinct parents across two packages depend on it, so it is filed undercorewhile behaving like a shared foundation under everything.@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.@boundary,@invariant,@entryPointand@layerNameremain in the Petrinaut source and are ignored. 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 derived from the id's last segment, so a few read plainly (uirenders as "Ui").petrinaut-cli,petrinaut-websiteandpetrinaut-opthave no declarations. A TypeScript package is a config entry plus one root declaration; the Python app needs docstring extraction, which is not written — configuring a package for a language with no extractor is a hard error rather than a silent no-op.mise run fix:package-jsoncould not run locally (needs a nightly Cargo feature), sopackage.jsonkey ordering was verified by reading the sorter's field list.🐾 Next steps
🛡 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?
yarn workspace @local/petrinaut-arch-docs lint:arch-docs # expect 0 errors turbo run doc:architecture --filter @local/petrinaut-arch-docsThen read
libs/@local/petrinaut-arch-docs/bundle/architecture.md— the whole architecture in one file — and open a diagram frombundle/diagrams/.around/core.types.svgexercises the neighbour cap: 12 drawn, plus a dashed "+6 further layers" node.Break something and confirm it is caught: change a
role:in any layer-declaring README to empty, add a source file in a folder no declaration covers, or point a rule inarchitecture.config.tsat a dependency that exists — each failslint:arch-docswith the offending file named.