From 490efd40a047e8cf7afbffb35f01eada39d77a5b Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 12 Aug 2026 02:41:18 +0200 Subject: [PATCH] Generate the Petrinaut architecture docs from in-code annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` names the layer a folder and its descendants form; `@role ` 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. --- AGENTS.md | 20 + .../docs/architecture/dependency-diagrams.md | 26 - .../petrinaut-compilation-dependencies.d2 | 76 --- .../petrinaut-compilation-dependencies.svg | 358 ------------ .../architecture/petrinaut-dependencies.d2 | 118 ---- .../architecture/petrinaut-dependencies.svg | 358 ------------ libs/@hashintel/petrinaut-core/package.json | 2 - .../scripts/generate-dependency-diagrams.mjs | 356 ------------ .../petrinaut-core/src/actual-mode/README.md | 5 + .../petrinaut-core/src/clipboard/paste.ts | 5 + .../petrinaut-core/src/examples/index.ts | 5 + .../src/file-format/parse-sdcpn-file.ts | 5 + .../petrinaut-core/src/handle/index.ts | 5 + .../petrinaut-core/src/hir/README.md | 5 + libs/@hashintel/petrinaut-core/src/index.ts | 13 +- .../petrinaut-core/src/layout/index.ts | 5 + .../petrinaut-core/src/lsp/index.ts | 5 + .../src/lsp/worker/language-server.worker.ts | 3 + .../petrinaut-core/src/playback/index.ts | 5 + .../src/schemas/entity-schemas.ts | 5 + .../src/simulation/ARCHITECTURE.md | 6 +- .../petrinaut-core/src/simulation/README.md | 13 +- .../src/simulation/authoring/sandbox.ts | 3 + .../src/simulation/engine/README.md | 5 + .../src/simulation/frames/frame-reader.ts | 5 + .../src/simulation/monte-carlo/README.md | 5 + .../src/simulation/runtime/simulation.ts | 5 + .../src/simulation/worker/README.md | 5 + .../petrinaut-core/src/store/index.ts | 5 + .../petrinaut-core/src/types/sdcpn.ts | 5 + .../petrinaut-core/src/validation/README.md | 5 + .../petrinaut-core/src/workers/README.md | 20 + libs/@hashintel/petrinaut/ARCHITECTURE.md | 6 +- libs/@hashintel/petrinaut/src/main.ts | 12 + .../src/react/execution-frame/provider.tsx | 5 + .../src/react/experiments/provider.tsx | 5 + .../petrinaut/src/react/hooks/index.ts | 5 + libs/@hashintel/petrinaut/src/react/index.ts | 13 +- .../petrinaut/src/react/lsp/provider.tsx | 5 + .../petrinaut/src/react/playback/README.md | 5 + .../src/react/simulation/provider.tsx | 5 + .../petrinaut/src/react/state/README.md | 11 + libs/@hashintel/petrinaut/src/ui/index.ts | 15 +- .../petrinaut/src/ui/monaco/provider.tsx | 5 + .../src/ui/views/Editor/editor-view.tsx | 5 + .../petrinaut/src/ui/views/README.md | 8 + .../src/ui/views/SDCPN/sdcpn-view.tsx | 5 + libs/@local/petrinaut-arch-docs/.gitignore | 9 + .../@local/petrinaut-arch-docs/.oxlintrc.json | 35 ++ .../petrinaut-arch-docs/LICENSE-APACHE.md | 189 +++++++ .../@local/petrinaut-arch-docs/LICENSE-MIT.md | 21 + libs/@local/petrinaut-arch-docs/LICENSE.md | 3 + libs/@local/petrinaut-arch-docs/README.md | 285 ++++++++++ .../architecture.config.ts | 107 ++++ .../dependency-cruiser.tsconfig.json | 17 +- libs/@local/petrinaut-arch-docs/package.json | 38 ++ libs/@local/petrinaut-arch-docs/src/build.ts | 356 ++++++++++++ .../petrinaut-arch-docs/src/check.test.ts | 195 +++++++ libs/@local/petrinaut-arch-docs/src/check.ts | 137 +++++ libs/@local/petrinaut-arch-docs/src/cli.ts | 154 ++++++ .../@local/petrinaut-arch-docs/src/content.ts | 228 ++++++++ .../petrinaut-arch-docs/src/diagnostics.ts | 33 ++ .../src/emit/bundle-outputs.ts | 139 +++++ .../petrinaut-arch-docs/src/emit/d2.test.ts | 129 +++++ .../@local/petrinaut-arch-docs/src/emit/d2.ts | 421 ++++++++++++++ .../petrinaut-arch-docs/src/emit/mdx.test.ts | 125 +++++ .../petrinaut-arch-docs/src/emit/mdx.ts | 523 ++++++++++++++++++ .../petrinaut-arch-docs/src/extract.test.ts | 216 ++++++++ .../@local/petrinaut-arch-docs/src/extract.ts | 388 +++++++++++++ .../src/frontmatter.test.ts | 160 ++++++ .../petrinaut-arch-docs/src/frontmatter.ts | 140 +++++ libs/@local/petrinaut-arch-docs/src/graph.ts | 292 ++++++++++ libs/@local/petrinaut-arch-docs/src/index.ts | 5 + libs/@local/petrinaut-arch-docs/src/model.ts | 120 ++++ libs/@local/petrinaut-arch-docs/src/paths.ts | 14 + .../petrinaut-arch-docs/src/scope.test.ts | 95 ++++ libs/@local/petrinaut-arch-docs/src/scope.ts | 56 ++ .../petrinaut-arch-docs/src/tags.test.ts | 110 ++++ libs/@local/petrinaut-arch-docs/src/tags.ts | 206 +++++++ libs/@local/petrinaut-arch-docs/tsconfig.json | 19 + libs/@local/petrinaut-arch-docs/turbo.json | 20 + yarn.lock | 20 +- 82 files changed, 5257 insertions(+), 1320 deletions(-) delete mode 100644 libs/@hashintel/petrinaut-core/docs/architecture/dependency-diagrams.md delete mode 100644 libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-compilation-dependencies.d2 delete mode 100644 libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-compilation-dependencies.svg delete mode 100644 libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-dependencies.d2 delete mode 100644 libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-dependencies.svg delete mode 100644 libs/@hashintel/petrinaut-core/scripts/generate-dependency-diagrams.mjs create mode 100644 libs/@hashintel/petrinaut-core/src/workers/README.md create mode 100644 libs/@hashintel/petrinaut/src/react/state/README.md create mode 100644 libs/@hashintel/petrinaut/src/ui/views/README.md create mode 100644 libs/@local/petrinaut-arch-docs/.gitignore create mode 100644 libs/@local/petrinaut-arch-docs/.oxlintrc.json create mode 100644 libs/@local/petrinaut-arch-docs/LICENSE-APACHE.md create mode 100644 libs/@local/petrinaut-arch-docs/LICENSE-MIT.md create mode 100644 libs/@local/petrinaut-arch-docs/LICENSE.md create mode 100644 libs/@local/petrinaut-arch-docs/README.md create mode 100644 libs/@local/petrinaut-arch-docs/architecture.config.ts rename libs/{@hashintel/petrinaut-core => @local/petrinaut-arch-docs}/dependency-cruiser.tsconfig.json (54%) create mode 100644 libs/@local/petrinaut-arch-docs/package.json create mode 100644 libs/@local/petrinaut-arch-docs/src/build.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/check.test.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/check.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/cli.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/content.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/diagnostics.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/emit/bundle-outputs.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/emit/d2.test.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/emit/d2.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/emit/mdx.test.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/emit/mdx.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/extract.test.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/extract.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/frontmatter.test.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/frontmatter.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/graph.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/index.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/model.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/paths.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/scope.test.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/scope.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/tags.test.ts create mode 100644 libs/@local/petrinaut-arch-docs/src/tags.ts create mode 100644 libs/@local/petrinaut-arch-docs/tsconfig.json create mode 100644 libs/@local/petrinaut-arch-docs/turbo.json diff --git a/AGENTS.md b/AGENTS.md index 2575a5db8ff..900a948f282 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,26 @@ When you change UI or behaviour in the petrinaut packages (`libs/@hashintel/petr If a change ships without doc updates, call that out in your summary so the user can decide whether to follow up. +### Petrinaut architecture docs + +Distinct from the user guide above: the **architecture** docs describe the shape of the code for the people (and agents) working on it. They are generated from annotations in the source by `@local/petrinaut-arch-docs`, and building the bundle fails when they drift. + +The architecture is declared **next to the code it describes** — never in a central mapping file. Two tags, and that is the whole vocabulary: + +- `@layerRoot ` plus `@role ` in a doc comment on a folder's primary file declares a layer. Prefer this — it needs no new file. +- A folder's `README.md` frontmatter (`layer` and `role`) does the same, and the prose below becomes that layer's page. Use it when the folder has real prose to carry, or when no single file is the obvious host. +- Files with no annotation inherit from the nearest declaring ancestor, so 37 declarations cover 412 files. Do not annotate every file. + +Any other tag is ignored, so do not add one expecting it to appear in the docs. In a declaring README's frontmatter, `layer` and `role` are the only keys and anything else fails the build. + +The generated docs are **build output and are not committed** — there is nothing to regenerate before pushing. Only the annotations are versioned. + +When you change structure in `libs/@hashintel/petrinaut-core` or `libs/@hashintel/petrinaut`, you MUST add a declaration if you introduce a folder that is a genuinely new architectural unit — a new boundary or a distinct responsibility, not merely a new directory. + +Verify with `yarn workspace @local/petrinaut-arch-docs lint:arch-docs`, which fails on unannotated files, undeclared ancestors and rule violations. To read the docs, `turbo run doc:architecture --filter @local/petrinaut-arch-docs` writes the bundle to `libs/@local/petrinaut-arch-docs/bundle/` (git-ignored); open `bundle/architecture.md` for the entire model in one file. + +Full reference: `libs/@local/petrinaut-arch-docs/README.md`. + ## Contextual Rules CRITICAL: For the files referenced below, use your Read tool to load it on a need-to-know basis, ONLY when relevant to the SPECIFIC task at hand: diff --git a/libs/@hashintel/petrinaut-core/docs/architecture/dependency-diagrams.md b/libs/@hashintel/petrinaut-core/docs/architecture/dependency-diagrams.md deleted file mode 100644 index 7fca3d59c08..00000000000 --- a/libs/@hashintel/petrinaut-core/docs/architecture/dependency-diagrams.md +++ /dev/null @@ -1,26 +0,0 @@ -# Petrinaut dependency diagrams - -These diagrams are generated from Petrinaut's TypeScript imports with -[dependency-cruiser](https://github.com/sverweij/dependency-cruiser) and laid -out with [D2](https://d2lang.com/) using ELK. - -## Project modules - -[Open the project dependency diagram](./petrinaut-dependencies.svg). - -![Dependencies between Petrinaut modules](./petrinaut-dependencies.svg) - -## Compilation and execution path - -[Open the focused compilation dependency diagram](./petrinaut-compilation-dependencies.svg). - -![Dependencies around the LSP, HIR compilation, and simulation runtimes](./petrinaut-compilation-dependencies.svg) - -Regenerate both diagrams from the repository root: - -```sh -yarn workspace @hashintel/petrinaut-core doc:dependency-diagram -``` - -The checked-in `.d2` files are the readable graph sources; the `.svg` files are -generated views. Test and Storybook files are intentionally excluded. diff --git a/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-compilation-dependencies.d2 b/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-compilation-dependencies.d2 deleted file mode 100644 index 44b4b9019e1..00000000000 --- a/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-compilation-dependencies.d2 +++ /dev/null @@ -1,76 +0,0 @@ -# Generated by scripts/generate-dependency-diagrams.mjs. Do not edit. - -direction: right - -# modules - -"core / HIR artifacts": {class: core; tooltip: "1 source file"} -"core / HIR compiler": {class: core; tooltip: "7 source files"} -"core / HIR compiler API": {class: core; tooltip: "1 source file"} -"core / HIR emitters": {class: core; tooltip: "2 source files"} -"core / HIR runtime API": {class: core; tooltip: "2 source files"} -"core / LSP client & transport": {class: core; tooltip: "4 source files"} -"core / LSP services": {class: core; tooltip: "10 source files"} -"core / LSP worker": {class: core; tooltip: "3 source files"} -"core / Monte Carlo runtime": {class: core; tooltip: "22 source files"} -"core / simulation assembly": {class: core; tooltip: "1 source file"} -"core / simulation controller": {class: core; tooltip: "3 source files"} -"core / simulation frames & metrics": {class: core; tooltip: "4 source files"} -"core / simulation worker": {class: core; tooltip: "5 source files"} -"React / experiments provider": {class: react; tooltip: "2 source files"} -"React / LSP provider": {class: react; tooltip: "3 source files"} -"React / simulation provider": {class: react; tooltip: "4 source files"} -"UI / experiment authoring": {class: ui; tooltip: "6 source files"} -"UI / metric authoring": {class: ui; tooltip: "7 source files"} -"UI / scenario authoring": {class: ui; tooltip: "1 source file"} -"UI / simulation timeline": {class: ui; tooltip: "16 source files"} - -# dependencies - -"core / HIR compiler API" -> "core / HIR artifacts": {tooltip: "1 file-level dependency"} -"core / HIR compiler API" -> "core / HIR compiler": {tooltip: "7 file-level dependencies"} -"core / HIR compiler API" -> "core / HIR emitters": {tooltip: "2 file-level dependencies"} -"core / HIR compiler API" -> "core / HIR runtime API": {tooltip: "1 file-level dependency"} -"core / HIR compiler" -> "core / HIR artifacts": {tooltip: "1 file-level dependency"} -"core / HIR compiler" -> "core / HIR emitters": {tooltip: "2 file-level dependencies"} -"core / HIR compiler" -> "core / HIR runtime API": {tooltip: "1 file-level dependency"} -"core / HIR emitters" -> "core / HIR compiler": {tooltip: "5 file-level dependencies"} -"core / HIR runtime API" -> "core / HIR artifacts": {tooltip: "1 file-level dependency"} -"core / LSP client & transport" -> "core / HIR compiler API": {tooltip: "1 file-level dependency"} -"core / LSP client & transport" -> "core / LSP worker": {tooltip: "4 file-level dependencies"} -"core / LSP services" -> "core / HIR compiler API": {tooltip: "4 file-level dependencies"} -"core / LSP worker" -> "core / HIR compiler API": {tooltip: "1 file-level dependency"} -"core / LSP worker" -> "core / LSP services": {tooltip: "7 file-level dependencies"} -"core / Monte Carlo runtime" -> "core / HIR runtime API": {tooltip: "4 file-level dependencies"} -"core / Monte Carlo runtime" -> "core / simulation assembly": {tooltip: "1 file-level dependency"} -"core / Monte Carlo runtime" -> "core / simulation controller": {tooltip: "1 file-level dependency"} -"core / Monte Carlo runtime" -> "core / simulation frames & metrics": {tooltip: "4 file-level dependencies"} -"core / simulation assembly" -> "core / HIR runtime API": {tooltip: "2 file-level dependencies"} -"core / simulation assembly" -> "core / simulation frames & metrics": {tooltip: "1 file-level dependency"} -"core / simulation controller" -> "core / simulation frames & metrics": {tooltip: "1 file-level dependency"} -"core / simulation controller" -> "core / simulation worker": {tooltip: "3 file-level dependencies"} -"core / simulation frames & metrics" -> "core / HIR runtime API": {tooltip: "1 file-level dependency"} -"core / simulation worker" -> "core / HIR runtime API": {tooltip: "1 file-level dependency"} -"core / simulation worker" -> "core / simulation assembly": {tooltip: "1 file-level dependency"} -"core / simulation worker" -> "core / simulation frames & metrics": {tooltip: "1 file-level dependency"} -"React / experiments provider" -> "core / Monte Carlo runtime": {tooltip: "1 file-level dependency"} -"React / experiments provider" -> "React / LSP provider": {tooltip: "1 file-level dependency"} -"React / LSP provider" -> "core / LSP client & transport": {tooltip: "2 file-level dependencies"} -"React / simulation provider" -> "core / simulation worker": {tooltip: "1 file-level dependency"} -"React / simulation provider" -> "React / LSP provider": {tooltip: "1 file-level dependency"} -"UI / experiment authoring" -> "React / experiments provider": {tooltip: "5 file-level dependencies"} -"UI / experiment authoring" -> "React / LSP provider": {tooltip: "1 file-level dependency"} -"UI / experiment authoring" -> "UI / metric authoring": {tooltip: "2 file-level dependencies"} -"UI / metric authoring" -> "React / LSP provider": {tooltip: "4 file-level dependencies"} -"UI / simulation timeline" -> "core / HIR runtime API": {tooltip: "1 file-level dependency"} -"UI / simulation timeline" -> "React / LSP provider": {tooltip: "1 file-level dependency"} -"UI / simulation timeline" -> "React / simulation provider": {tooltip: "1 file-level dependency"} -"UI / simulation timeline" -> "UI / metric authoring": {tooltip: "2 file-level dependencies"} - -# styling - -classes: { - core: {style.fill: "#dcecff"; style.stroke: "#3676b8"} - react: {style.fill: "#e8e0ff"; style.stroke: "#7051b5"} - ui: {style.fill: "#e2f4e8"; style.stroke: "#3d8055"} -} diff --git a/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-compilation-dependencies.svg b/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-compilation-dependencies.svg deleted file mode 100644 index 3773273135c..00000000000 --- a/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-compilation-dependencies.svg +++ /dev/null @@ -1,358 +0,0 @@ -core / HIR artifacts1 source filecore / HIR compiler7 source filescore / HIR compiler API1 source filecore / HIR emitters2 source filescore / HIR runtime API2 source filescore / LSP client & transport4 source filescore / LSP services10 source filescore / LSP worker3 source filescore / Monte Carlo runtime22 source filescore / simulation assembly1 source filecore / simulation controller3 source filescore / simulation frames & metrics4 source filescore / simulation worker5 source filesReact / experiments provider2 source filesReact / LSP provider3 source filesReact / simulation provider4 source filesUI / experiment authoring6 source filesUI / metric authoring7 source filesUI / scenario authoring1 source fileUI / simulation timeline16 source files 1 source file - - - - - - - - - - - - -7 source files - - - - - - - - - - - - -1 source file - - - - - - - - - - - - -2 source files - - - - - - - - - - - - -2 source files - - - - - - - - - - - - -4 source files - - - - - - - - - - - - -10 source files - - - - - - - - - - - - -3 source files - - - - - - - - - - - - -22 source files - - - - - - - - - - - - -1 source file - - - - - - - - - - - - -3 source files - - - - - - - - - - - - -4 source files - - - - - - - - - - - - -5 source files - - - - - - - - - - - - -2 source files - - - - - - - - - - - - -3 source files - - - - - - - - - - - - -4 source files - - - - - - - - - - - - -6 source files - - - - - - - - - - - - -7 source files - - - - - - - - - - - - -1 source file - - - - - - - - - - - - -16 source files - - - - - - - - - - - - - - - - diff --git a/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-dependencies.d2 b/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-dependencies.d2 deleted file mode 100644 index 117dbf97a8a..00000000000 --- a/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-dependencies.d2 +++ /dev/null @@ -1,118 +0,0 @@ -# Generated by scripts/generate-dependency-diagrams.mjs. Do not edit. - -direction: right - -# modules - -"core / AI tools": {class: core; tooltip: "1 source file"} -"core / editing & document state": {class: core; tooltip: "15 source files"} -"core / examples": {class: core; tooltip: "6 source files"} -"core / HIR compiler & runtime": {class: core; tooltip: "13 source files"} -"core / LSP": {class: core; tooltip: "17 source files"} -"core / model & persistence": {class: core; tooltip: "22 source files"} -"core / Monte Carlo runtime": {class: core; tooltip: "22 source files"} -"core / shared model API": {class: core; tooltip: "19 source files"} -"core / simulation engine": {class: core; tooltip: "29 source files"} -"core / simulation runtime & workers": {class: core; tooltip: "8 source files"} -"Petrinaut / public API": {class: ui; tooltip: "2 source files"} -"React / editor state": {class: react; tooltip: "40 source files"} -"React / LSP": {class: react; tooltip: "2 source files"} -"React / playback & actual mode": {class: react; tooltip: "5 source files"} -"React / simulation & experiments": {class: react; tooltip: "5 source files"} -"UI / canvas": {class: ui; tooltip: "22 source files"} -"UI / development tools": {class: ui; tooltip: "6 source files"} -"UI / editor": {class: ui; tooltip: "115 source files"} -"UI / shared components & infrastructure": {class: ui; tooltip: "53 source files"} -"UI / shared views": {class: ui; tooltip: "1 source file"} - -# dependencies - -"core / AI tools" -> "core / examples": {tooltip: "1 file-level dependency"} -"core / AI tools" -> "core / shared model API": {tooltip: "6 file-level dependencies"} -"core / editing & document state" -> "core / model & persistence": {tooltip: "1 file-level dependency"} -"core / editing & document state" -> "core / shared model API": {tooltip: "22 file-level dependencies"} -"core / examples" -> "core / shared model API": {tooltip: "8 file-level dependencies"} -"core / HIR compiler & runtime" -> "core / shared model API": {tooltip: "7 file-level dependencies"} -"core / HIR compiler & runtime" -> "core / simulation engine": {tooltip: "2 file-level dependencies"} -"core / LSP" -> "core / editing & document state": {tooltip: "1 file-level dependency"} -"core / LSP" -> "core / HIR compiler & runtime": {tooltip: "6 file-level dependencies"} -"core / LSP" -> "core / shared model API": {tooltip: "18 file-level dependencies"} -"core / LSP" -> "core / simulation engine": {tooltip: "1 file-level dependency"} -"core / model & persistence" -> "core / editing & document state": {tooltip: "1 file-level dependency"} -"core / model & persistence" -> "core / shared model API": {tooltip: "12 file-level dependencies"} -"core / model & persistence" -> "core / simulation engine": {tooltip: "3 file-level dependencies"} -"core / Monte Carlo runtime" -> "core / editing & document state": {tooltip: "1 file-level dependency"} -"core / Monte Carlo runtime" -> "core / HIR compiler & runtime": {tooltip: "4 file-level dependencies"} -"core / Monte Carlo runtime" -> "core / shared model API": {tooltip: "14 file-level dependencies"} -"core / Monte Carlo runtime" -> "core / simulation engine": {tooltip: "24 file-level dependencies"} -"core / Monte Carlo runtime" -> "core / simulation runtime & workers": {tooltip: "1 file-level dependency"} -"core / shared model API" -> "core / AI tools": {tooltip: "2 file-level dependencies"} -"core / shared model API" -> "core / editing & document state": {tooltip: "15 file-level dependencies"} -"core / shared model API" -> "core / HIR compiler & runtime": {tooltip: "1 file-level dependency"} -"core / shared model API" -> "core / LSP": {tooltip: "3 file-level dependencies"} -"core / shared model API" -> "core / model & persistence": {tooltip: "13 file-level dependencies"} -"core / shared model API" -> "core / simulation engine": {tooltip: "12 file-level dependencies"} -"core / simulation engine" -> "core / editing & document state": {tooltip: "1 file-level dependency"} -"core / simulation engine" -> "core / HIR compiler & runtime": {tooltip: "7 file-level dependencies"} -"core / simulation engine" -> "core / Monte Carlo runtime": {tooltip: "2 file-level dependencies"} -"core / simulation engine" -> "core / shared model API": {tooltip: "27 file-level dependencies"} -"core / simulation engine" -> "core / simulation runtime & workers": {tooltip: "2 file-level dependencies"} -"core / simulation runtime & workers" -> "core / editing & document state": {tooltip: "1 file-level dependency"} -"core / simulation runtime & workers" -> "core / HIR compiler & runtime": {tooltip: "1 file-level dependency"} -"core / simulation runtime & workers" -> "core / shared model API": {tooltip: "10 file-level dependencies"} -"core / simulation runtime & workers" -> "core / simulation engine": {tooltip: "10 file-level dependencies"} -"Petrinaut / public API" -> "core / shared model API": {tooltip: "1 file-level dependency"} -"Petrinaut / public API" -> "React / editor state": {tooltip: "2 file-level dependencies"} -"Petrinaut / public API" -> "UI / shared components & infrastructure": {tooltip: "4 file-level dependencies"} -"React / editor state" -> "core / shared model API": {tooltip: "23 file-level dependencies"} -"React / editor state" -> "React / LSP": {tooltip: "2 file-level dependencies"} -"React / editor state" -> "React / playback & actual mode": {tooltip: "6 file-level dependencies"} -"React / editor state" -> "React / simulation & experiments": {tooltip: "6 file-level dependencies"} -"React / LSP" -> "core / LSP": {tooltip: "2 file-level dependencies"} -"React / LSP" -> "core / shared model API": {tooltip: "2 file-level dependencies"} -"React / LSP" -> "React / editor state": {tooltip: "3 file-level dependencies"} -"React / playback & actual mode" -> "core / shared model API": {tooltip: "7 file-level dependencies"} -"React / playback & actual mode" -> "React / editor state": {tooltip: "5 file-level dependencies"} -"React / playback & actual mode" -> "React / simulation & experiments": {tooltip: "3 file-level dependencies"} -"React / simulation & experiments" -> "core / Monte Carlo runtime": {tooltip: "1 file-level dependency"} -"React / simulation & experiments" -> "core / shared model API": {tooltip: "5 file-level dependencies"} -"React / simulation & experiments" -> "core / simulation runtime & workers": {tooltip: "1 file-level dependency"} -"React / simulation & experiments" -> "React / editor state": {tooltip: "11 file-level dependencies"} -"React / simulation & experiments" -> "React / LSP": {tooltip: "2 file-level dependencies"} -"UI / canvas" -> "core / shared model API": {tooltip: "5 file-level dependencies"} -"UI / canvas" -> "React / editor state": {tooltip: "30 file-level dependencies"} -"UI / canvas" -> "React / playback & actual mode": {tooltip: "3 file-level dependencies"} -"UI / canvas" -> "React / simulation & experiments": {tooltip: "3 file-level dependencies"} -"UI / canvas" -> "UI / shared components & infrastructure": {tooltip: "13 file-level dependencies"} -"UI / canvas" -> "UI / shared views": {tooltip: "1 file-level dependency"} -"UI / development tools" -> "core / shared model API": {tooltip: "6 file-level dependencies"} -"UI / development tools" -> "UI / shared components & infrastructure": {tooltip: "3 file-level dependencies"} -"UI / editor" -> "core / examples": {tooltip: "2 file-level dependencies"} -"UI / editor" -> "core / HIR compiler & runtime": {tooltip: "1 file-level dependency"} -"UI / editor" -> "core / shared model API": {tooltip: "63 file-level dependencies"} -"UI / editor" -> "React / editor state": {tooltip: "139 file-level dependencies"} -"UI / editor" -> "React / LSP": {tooltip: "13 file-level dependencies"} -"UI / editor" -> "React / playback & actual mode": {tooltip: "11 file-level dependencies"} -"UI / editor" -> "React / simulation & experiments": {tooltip: "16 file-level dependencies"} -"UI / editor" -> "UI / canvas": {tooltip: "2 file-level dependencies"} -"UI / editor" -> "UI / shared components & infrastructure": {tooltip: "147 file-level dependencies"} -"UI / editor" -> "UI / shared views": {tooltip: "1 file-level dependency"} -"UI / shared components & infrastructure" -> "core / shared model API": {tooltip: "15 file-level dependencies"} -"UI / shared components & infrastructure" -> "React / editor state": {tooltip: "6 file-level dependencies"} -"UI / shared components & infrastructure" -> "React / LSP": {tooltip: "4 file-level dependencies"} -"UI / shared components & infrastructure" -> "UI / canvas": {tooltip: "1 file-level dependency"} -"UI / shared components & infrastructure" -> "UI / editor": {tooltip: "14 file-level dependencies"} -"UI / shared views" -> "core / shared model API": {tooltip: "2 file-level dependencies"} -"UI / shared views" -> "React / editor state": {tooltip: "1 file-level dependency"} -"UI / shared views" -> "React / playback & actual mode": {tooltip: "1 file-level dependency"} -"UI / shared views" -> "React / simulation & experiments": {tooltip: "1 file-level dependency"} -"UI / shared views" -> "UI / editor": {tooltip: "1 file-level dependency"} -"UI / shared views" -> "UI / shared components & infrastructure": {tooltip: "1 file-level dependency"} - -# styling - -classes: { - core: {style.fill: "#dcecff"; style.stroke: "#3676b8"} - react: {style.fill: "#e8e0ff"; style.stroke: "#7051b5"} - ui: {style.fill: "#e2f4e8"; style.stroke: "#3d8055"} -} diff --git a/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-dependencies.svg b/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-dependencies.svg deleted file mode 100644 index 3b1203b4487..00000000000 --- a/libs/@hashintel/petrinaut-core/docs/architecture/petrinaut-dependencies.svg +++ /dev/null @@ -1,358 +0,0 @@ -core / AI tools1 source filecore / editing & document state15 source filescore / examples6 source filescore / HIR compiler & runtime13 source filescore / LSP17 source filescore / model & persistence22 source filescore / Monte Carlo runtime22 source filescore / shared model API19 source filescore / simulation engine29 source filescore / simulation runtime & workers8 source filesPetrinaut / public API2 source filesReact / editor state40 source filesReact / LSP2 source filesReact / playback & actual mode5 source filesReact / simulation & experiments5 source filesUI / canvas22 source filesUI / development tools6 source filesUI / editor115 source filesUI / shared components & infrastructure53 source filesUI / shared views1 source file 1 source file - - - - - - - - - - - - -15 source files - - - - - - - - - - - - -6 source files - - - - - - - - - - - - -13 source files - - - - - - - - - - - - -17 source files - - - - - - - - - - - - -22 source files - - - - - - - - - - - - -22 source files - - - - - - - - - - - - -19 source files - - - - - - - - - - - - -29 source files - - - - - - - - - - - - -8 source files - - - - - - - - - - - - -2 source files - - - - - - - - - - - - -40 source files - - - - - - - - - - - - -2 source files - - - - - - - - - - - - -5 source files - - - - - - - - - - - - -5 source files - - - - - - - - - - - - -22 source files - - - - - - - - - - - - -6 source files - - - - - - - - - - - - -115 source files - - - - - - - - - - - - -53 source files - - - - - - - - - - - - -1 source file - - - - - - - - - - - - - - - - diff --git a/libs/@hashintel/petrinaut-core/package.json b/libs/@hashintel/petrinaut-core/package.json index 5f45d28c7c8..300fd370c0f 100644 --- a/libs/@hashintel/petrinaut-core/package.json +++ b/libs/@hashintel/petrinaut-core/package.json @@ -69,7 +69,6 @@ }, "scripts": { "build": "vite build", - "doc:dependency-diagram": "node scripts/generate-dependency-diagrams.mjs", "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", "lint:tsc": "tsgo --noEmit", @@ -86,7 +85,6 @@ "devDependencies": { "@types/node": "22.18.13", "@typescript/native-preview": "7.0.0-dev.20260511.1", - "dependency-cruiser": "18.0.0", "oxlint": "1.63.0", "oxlint-tsgolint": "0.22.1", "rolldown": "1.1.2", diff --git a/libs/@hashintel/petrinaut-core/scripts/generate-dependency-diagrams.mjs b/libs/@hashintel/petrinaut-core/scripts/generate-dependency-diagrams.mjs deleted file mode 100644 index da80c52d321..00000000000 --- a/libs/@hashintel/petrinaut-core/scripts/generate-dependency-diagrams.mjs +++ /dev/null @@ -1,356 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { chmod, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { cruise } from "dependency-cruiser"; -import extractTSConfig from "dependency-cruiser/config-utl/extract-ts-config"; - -const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url)); -const outputDirectory = fileURLToPath( - new URL("../docs/architecture/", import.meta.url), -); -const tsconfigPath = fileURLToPath( - new URL("../dependency-cruiser.tsconfig.json", import.meta.url), -); - -const corePrefix = "libs/@hashintel/petrinaut-core/src/"; -const petrinautPrefix = "libs/@hashintel/petrinaut/src/"; -const coreSource = join(repoRoot, corePrefix); - -const coreAliases = [ - ["@hashintel/petrinaut-core/examples", "examples/index.ts"], - ["@hashintel/petrinaut-core/hir-runtime", "hir-runtime.ts"], - ["@hashintel/petrinaut-core/hir", "hir.ts"], - ["@hashintel/petrinaut-core/workers/lsp", "workers/lsp.ts"], - ["@hashintel/petrinaut-core/workers/monte-carlo", "workers/monte-carlo.ts"], - ["@hashintel/petrinaut-core/workers/simulation", "workers/simulation.ts"], - ["@hashintel/petrinaut-core", "index.ts"], -].map(([name, path]) => ({ - alias: join(coreSource, path), - name, - onlyModule: true, -})); - -const cruiseResult = await cruise( - ["libs/@hashintel/petrinaut-core/src", "libs/@hashintel/petrinaut/src"], - { - baseDir: repoRoot, - exclude: - "(?:[.](?:test|stories)[.][cm]?[jt]sx?$|/(?:__fixtures__|__snapshots__)/)", - includeOnly: "^libs/@hashintel/petrinaut(?:-core)?/src/", - moduleSystems: ["es6"], - tsPreCompilationDeps: true, - }, - { - alias: coreAliases, - conditionNames: ["types", "import", "default"], - extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"], - }, - { tsConfig: extractTSConfig(tsconfigPath) }, -); - -if (typeof cruiseResult.output === "string") { - throw new TypeError("dependency-cruiser returned formatted output"); -} - -/** @type {import("dependency-cruiser").ICruiseResult["modules"]} */ -const modules = cruiseResult.output.modules; - -/** - * @param {string} source - * @returns {string | null} - */ -function broadModule(source) { - if (source.startsWith(corePrefix)) { - const path = source.slice(corePrefix.length); - const [directory, child] = path.split("/"); - - if (directory === "hir" || directory.startsWith("hir")) { - return "core / HIR compiler & runtime"; - } - if (directory === "lsp" || path === "workers/lsp.ts") { - return "core / LSP"; - } - if (directory === "simulation" || directory === "workers") { - if (child === "monte-carlo" || path === "workers/monte-carlo.ts") { - return "core / Monte Carlo runtime"; - } - if ( - ["runtime", "worker"].includes(child) || - path === "workers/simulation.ts" - ) { - return "core / simulation runtime & workers"; - } - return "core / simulation engine"; - } - if ( - [ - "actions.ts", - "clipboard", - "commands.ts", - "handle", - "layout", - "store", - ].includes(directory) - ) { - return "core / editing & document state"; - } - if ( - [ - "actual-mode", - "file-format", - "playback", - "schemas", - "validation", - ].includes(directory) - ) { - return "core / model & persistence"; - } - if (directory === "examples") { - return "core / examples"; - } - if (directory === "ai.ts") { - return "core / AI tools"; - } - return "core / shared model API"; - } - - if (source.startsWith(petrinautPrefix)) { - const path = source.slice(petrinautPrefix.length); - const [directory, child, grandchild] = path.split("/"); - - if (!child) { - return "Petrinaut / public API"; - } - if (directory === "react") { - if (child === "lsp") { - return "React / LSP"; - } - if (["experiments", "simulation"].includes(child)) { - return "React / simulation & experiments"; - } - if ( - ["actual-mode-context.ts", "execution-frame", "playback"].includes( - child, - ) - ) { - return "React / playback & actual mode"; - } - return "React / editor state"; - } - if (directory === "ui" && child === "views") { - if (grandchild === "Editor") { - return "UI / editor"; - } - if (grandchild === "SDCPN") { - return "UI / canvas"; - } - return "UI / shared views"; - } - if (directory === "ui" && child === "dev") { - return "UI / development tools"; - } - return "UI / shared components & infrastructure"; - } - - return null; -} - -/** - * @param {string} source - * @returns {string | null} - */ -function compilationModule(source) { - if (source.startsWith(corePrefix)) { - const path = source.slice(corePrefix.length); - - if (path.startsWith("lsp/worker/")) { - return "core / LSP worker"; - } - if (path.startsWith("lsp/lib/")) { - return "core / LSP services"; - } - if (path.startsWith("lsp/") || path === "workers/lsp.ts") { - return "core / LSP client & transport"; - } - if (path === "hir.ts") { - return "core / HIR compiler API"; - } - if (path === "hir-runtime.ts" || path === "hir/instantiate.ts") { - return "core / HIR runtime API"; - } - if (path.startsWith("hir/emit-")) { - return "core / HIR emitters"; - } - if (path === "hir/artifact-fingerprint.ts") { - return "core / HIR artifacts"; - } - if (path.startsWith("hir/")) { - return "core / HIR compiler"; - } - if (path === "simulation/engine/build-simulation.ts") { - return "core / simulation assembly"; - } - if (path.startsWith("simulation/frames/")) { - return "core / simulation frames & metrics"; - } - if (path.startsWith("simulation/runtime/")) { - return "core / simulation controller"; - } - if ( - path.startsWith("simulation/worker/") || - path === "workers/simulation.ts" - ) { - return "core / simulation worker"; - } - if ( - path.startsWith("simulation/monte-carlo/") || - path === "workers/monte-carlo.ts" - ) { - return "core / Monte Carlo runtime"; - } - return null; - } - - if (!source.startsWith(petrinautPrefix)) { - return null; - } - - const path = source.slice(petrinautPrefix.length); - if (path.startsWith("react/lsp/") || path === "react/hooks/use-lsp.ts") { - return "React / LSP provider"; - } - if ( - path.startsWith("react/simulation/") || - path === "react/hooks/use-simulation.ts" - ) { - return "React / simulation provider"; - } - if (path.startsWith("react/experiments/")) { - return "React / experiments provider"; - } - if (path.includes("/SimulateView/metrics/")) { - return "UI / metric authoring"; - } - if (path.includes("/SimulateView/experiments/")) { - return "UI / experiment authoring"; - } - if (path.includes("/simulation-timeline/")) { - return "UI / simulation timeline"; - } - if (path.endsWith("/scenario-lsp.ts")) { - return "UI / scenario authoring"; - } - return null; -} - -/** @param {string} name */ -function moduleClass(name) { - if (name.startsWith("core /")) { - return "core"; - } - if (name.startsWith("React /")) { - return "react"; - } - return "ui"; -} - -/** @param {(source: string) => string | null} classify */ -function buildGraph(classify) { - /** @type {Map>} */ - const nodes = new Map(); - /** @type {Map} */ - const edges = new Map(); - - for (const module of modules) { - const from = classify(module.source); - if (from) { - const sources = nodes.get(from) ?? new Set(); - sources.add(module.source); - nodes.set(from, sources); - } - - for (const dependency of module.dependencies) { - const to = classify(dependency.resolved); - if (!from || !to || from === to) { - continue; - } - - const sources = nodes.get(to) ?? new Set(); - sources.add(dependency.resolved); - nodes.set(to, sources); - - const edge = `${from}\u0000${to}`; - edges.set(edge, (edges.get(edge) ?? 0) + 1); - } - } - - const nodeLines = [...nodes.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([name, sources]) => { - const fileLabel = `${sources.size} source ${sources.size === 1 ? "file" : "files"}`; - return `${JSON.stringify(name)}: {class: ${moduleClass(name)}; tooltip: ${JSON.stringify(fileLabel)}}`; - }); - const edgeLines = [...edges.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([edge, count]) => { - const [from, to] = edge.split("\u0000"); - return `${JSON.stringify(from)} -> ${JSON.stringify(to)}: {tooltip: ${JSON.stringify(`${count} file-level ${count === 1 ? "dependency" : "dependencies"}`)}}`; - }); - - return ( - `# Generated by scripts/generate-dependency-diagrams.mjs. Do not edit.\n\n` + - `direction: right\n\n` + - `# modules\n\n${nodeLines.join("\n")}\n\n` + - `# dependencies\n\n${edgeLines.join("\n")}\n\n` + - `# styling\n\nclasses: {\n` + - ` core: {style.fill: "#dcecff"; style.stroke: "#3676b8"}\n` + - ` react: {style.fill: "#e8e0ff"; style.stroke: "#7051b5"}\n` + - ` ui: {style.fill: "#e2f4e8"; style.stroke: "#3d8055"}\n` + - `}\n` - ); -} - -/** - * @param {string} sourcePath - * @param {string} outputPath - */ -function renderD2(sourcePath, outputPath) { - const result = spawnSync( - "mise", - [ - "exec", - "--env", - "dev", - "--", - "d2", - "--layout", - "elk", - sourcePath, - outputPath, - ], - { cwd: repoRoot, encoding: "utf8" }, - ); - - if (result.status !== 0) { - throw new Error(result.stderr || result.stdout || "D2 rendering failed"); - } -} - -/** @type {Array<[string, (source: string) => string | null]>} */ -const diagrams = [ - ["petrinaut-dependencies", broadModule], - ["petrinaut-compilation-dependencies", compilationModule], -]; - -for (const [name, classify] of diagrams) { - const sourcePath = `${outputDirectory}${name}.d2`; - const outputPath = `${outputDirectory}${name}.svg`; - await writeFile(sourcePath, buildGraph(classify)); - renderD2(sourcePath, outputPath); - await chmod(outputPath, 0o644); - process.stdout.write( - `Generated ${sourcePath.slice(repoRoot.length)} and ${outputPath.slice(repoRoot.length)}\n`, - ); -} diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/README.md b/libs/@hashintel/petrinaut-core/src/actual-mode/README.md index 27b501d83cb..ace369c6efd 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/README.md +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/README.md @@ -1,3 +1,8 @@ +--- +layer: core.actual-mode +role: Renders an execution supplied by an external source rather than by simulation +--- + # Actual Mode Core This folder contains the experimental, transport-neutral pieces of Petrinaut diff --git a/libs/@hashintel/petrinaut-core/src/clipboard/paste.ts b/libs/@hashintel/petrinaut-core/src/clipboard/paste.ts index 00c5fd626b3..8b9f52fef41 100644 --- a/libs/@hashintel/petrinaut-core/src/clipboard/paste.ts +++ b/libs/@hashintel/petrinaut-core/src/clipboard/paste.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.clipboard + * @role Serialises a selection and pastes it back, resolving name collisions + */ + import { v4 as generateUuid } from "uuid"; import { getArcEndpointPlaceId } from "../arc-endpoints"; diff --git a/libs/@hashintel/petrinaut-core/src/examples/index.ts b/libs/@hashintel/petrinaut-core/src/examples/index.ts index 9992c4d0e83..4d956abf4e3 100644 --- a/libs/@hashintel/petrinaut-core/src/examples/index.ts +++ b/libs/@hashintel/petrinaut-core/src/examples/index.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.examples + * @role Ready-made SDCPN documents shipped for onboarding and demos + */ + export { productionMachines } from "./production-with-machine-failure"; export { deploymentPipelineSDCPN } from "./deployment-pipeline"; export { probabilisticSatellitesSDCPN } from "./satellites-launcher"; diff --git a/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.ts b/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.ts index d9327873a4b..b147ee6b35d 100644 --- a/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.ts +++ b/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.file-format + * @role Reads and writes the on-disk SDCPN document format, plus export converters + */ + import { legacySdcpnFileSchema, SDCPN_FILE_FORMAT_VERSION, diff --git a/libs/@hashintel/petrinaut-core/src/handle/index.ts b/libs/@hashintel/petrinaut-core/src/handle/index.ts index 032e79a1835..50961200efa 100644 --- a/libs/@hashintel/petrinaut-core/src/handle/index.ts +++ b/libs/@hashintel/petrinaut-core/src/handle/index.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.handle + * @role Stateful handle wrapping a document, emitting change events to subscribers + */ + export { createJsonDocHandle, type CreateJsonDocHandleOptions, diff --git a/libs/@hashintel/petrinaut-core/src/hir/README.md b/libs/@hashintel/petrinaut-core/src/hir/README.md index 6d06a46ed70..0d172ea3a84 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/README.md +++ b/libs/@hashintel/petrinaut-core/src/hir/README.md @@ -1,3 +1,8 @@ +--- +layer: core.hir +role: Lowers user-authored TypeScript to a source-spanned IR, then typechecks, lints and emits it +--- + # Petrinaut HIR The HIR is Petrinaut's source-spanned intermediate representation for diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index 3e493eb241f..5ec4897768b 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -1,7 +1,12 @@ -// Public surface for `@hashintel/petrinaut-core` — the headless engine. -// -// No React, no DOM, no Monaco. Stateful handles, streams, and pure logic for -// SDCPN documents, simulation, LSP, and playback. +/** + * Public surface for `@hashintel/petrinaut-core` — the headless engine. + * + * No React, no DOM, no Monaco. Stateful handles, streams, and pure logic for + * SDCPN documents, simulation, LSP, and playback. + * + * @layerRoot core + * @role SDCPN document model, compiler, simulation runtimes and LSP, with no UI framework + */ // --- Document --- export { diff --git a/libs/@hashintel/petrinaut-core/src/layout/index.ts b/libs/@hashintel/petrinaut-core/src/layout/index.ts index 630c48d4435..ce337ba2f01 100644 --- a/libs/@hashintel/petrinaut-core/src/layout/index.ts +++ b/libs/@hashintel/petrinaut-core/src/layout/index.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.layout + * @role Computes node positions for a net, so auto-layout does not require the canvas + */ + export { calculateGraphLayout, type LayoutDimensions, diff --git a/libs/@hashintel/petrinaut-core/src/lsp/index.ts b/libs/@hashintel/petrinaut-core/src/lsp/index.ts index 415bd3a45c9..0526ac6399e 100644 --- a/libs/@hashintel/petrinaut-core/src/lsp/index.ts +++ b/libs/@hashintel/petrinaut-core/src/lsp/index.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.lsp + * @role Language-server client and transport for editing user code in the net + */ + export { createLanguageClient, type CreateLanguageClientConfig, diff --git a/libs/@hashintel/petrinaut-core/src/lsp/worker/language-server.worker.ts b/libs/@hashintel/petrinaut-core/src/lsp/worker/language-server.worker.ts index 4d95f74d5c9..0c352b578fa 100644 --- a/libs/@hashintel/petrinaut-core/src/lsp/worker/language-server.worker.ts +++ b/libs/@hashintel/petrinaut-core/src/lsp/worker/language-server.worker.ts @@ -7,6 +7,9 @@ * - Server push: `textDocument/publishDiagnostics` * * The LanguageService is created once and reused across SDCPN changes. + * + * @layerRoot core.lsp.worker + * @role Hosts the TypeScript language server off the main thread */ import ts from "typescript"; import { diff --git a/libs/@hashintel/petrinaut-core/src/playback/index.ts b/libs/@hashintel/petrinaut-core/src/playback/index.ts index b382d70a22b..8347b28b50b 100644 --- a/libs/@hashintel/petrinaut-core/src/playback/index.ts +++ b/libs/@hashintel/petrinaut-core/src/playback/index.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.playback + * @role Picks the viewed frame over time and defines the per-play-mode backpressure profiles + */ + export { createPlayback, formatPlaybackSpeed, diff --git a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts index fd48c8705db..4179d09c508 100644 --- a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts +++ b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.schemas + * @role Zod schemas for document entities, metrics and scenarios, and the descriptions the AI tools read + */ + import { z } from "zod"; import { getParameterValueError } from "../parameter-values"; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/ARCHITECTURE.md b/libs/@hashintel/petrinaut-core/src/simulation/ARCHITECTURE.md index d4ab5c66f91..ef14e3b0dbd 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/ARCHITECTURE.md +++ b/libs/@hashintel/petrinaut-core/src/simulation/ARCHITECTURE.md @@ -1,8 +1,8 @@ # Simulation Architecture -> For the illustrated deep-dive (memory maps, sequence diagrams, protocols), -> open [`../../docs/architecture/index.html`](../../docs/architecture/index.html) -> in a browser — no build step needed. +> For the deep-dive (memory model, frame format, protocols, Monte Carlo), see +> the architecture docs, added by #9205 under +> `libs/@local/petrinaut-arch-docs/content/simulation/`. The simulation module is split into five boundaries: diff --git a/libs/@hashintel/petrinaut-core/src/simulation/README.md b/libs/@hashintel/petrinaut-core/src/simulation/README.md index 3e300cd2718..df532582f7c 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/README.md +++ b/libs/@hashintel/petrinaut-core/src/simulation/README.md @@ -1,11 +1,16 @@ +--- +layer: core.simulation +role: Executes SDCPN nets — stepping, frames, workers and batch statistics +--- + # Simulation Module Headless SDCPN simulation runtime. -> Illustrated architecture documentation (memory layouts, sequence diagrams, -> protocols) lives in -> [`../../docs/architecture/index.html`](../../docs/architecture/index.html) — -> self-contained HTML, open it in a browser. +> Deep-dive documentation (memory layouts, the frame format, worker protocol, +> Monte Carlo) lives in the architecture docs. They are added by #9205 under +> `libs/@local/petrinaut-arch-docs/content/simulation/`, and #9206 adds a site +> that renders them. ## Overview diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/sandbox.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/sandbox.ts index 46048c77487..b83654180d5 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/authoring/sandbox.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/authoring/sandbox.ts @@ -2,6 +2,9 @@ * Shared hardening helpers for evaluating user-authored JS expressions * (scenario expressions, metric bodies, …). Co-located so the compilers * can't drift on what they consider "safe enough". + * + * @layerRoot core.simulation.authoring + * @role Compiles and sandboxes the code users write inside a net */ /** diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/README.md b/libs/@hashintel/petrinaut-core/src/simulation/engine/README.md index 58551a8f40e..30c16803904 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/README.md +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/README.md @@ -1,3 +1,8 @@ +--- +layer: core.simulation.engine +role: Builds an SDCPN definition into a runnable instance and computes frames +--- + # Simulation Engine Core simulation logic for SDCPN Petri net execution. diff --git a/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.ts b/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.ts index c5f7bf5cb5d..d8c67f6d6ec 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.simulation.frames + * @role The frame layout and the readers hosts use to inspect one frame + */ + import { readTokenRecord } from "../engine/token-layout"; import { createEngineFrameLayout, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/README.md b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/README.md index 84651a2c99b..782cebe0c6d 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/README.md +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/README.md @@ -1,3 +1,8 @@ +--- +layer: core.simulation.monte-carlo +role: Runs many independent simulations with bounded frame memory, reporting metric aggregates +--- + # Monte Carlo Simulator ## Goal diff --git a/libs/@hashintel/petrinaut-core/src/simulation/runtime/simulation.ts b/libs/@hashintel/petrinaut-core/src/simulation/runtime/simulation.ts index e2de82ea7a3..48ce9aa0716 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/runtime/simulation.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/runtime/simulation.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.simulation.runtime + * @role Host-side controller for a run — owns the transport, the frame store and the status streams + */ + import { DEFAULT_PETRINAUT_EXTENSIONS, sanitizeSDCPNForExtensions, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/worker/README.md b/libs/@hashintel/petrinaut-core/src/simulation/worker/README.md index 4184d8900f2..45f67f0b638 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/worker/README.md +++ b/libs/@hashintel/petrinaut-core/src/simulation/worker/README.md @@ -1,3 +1,8 @@ +--- +layer: core.simulation.worker +role: Computes simulation frames off the main thread under host backpressure +--- + # Simulation Worker Worker runtime for off-main-thread SDCPN simulation computation. diff --git a/libs/@hashintel/petrinaut-core/src/store/index.ts b/libs/@hashintel/petrinaut-core/src/store/index.ts index c319b60aae6..aa4d4444906 100644 --- a/libs/@hashintel/petrinaut-core/src/store/index.ts +++ b/libs/@hashintel/petrinaut-core/src/store/index.ts @@ -1 +1,6 @@ +/** + * @layerRoot core.store + * @role Minimal subscribable store primitive the core exposes instead of a framework dependency + */ + export { createReadableStore, type ReadableStore } from "./readable-store"; diff --git a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts index 99bcbcee03c..78cb73420e6 100644 --- a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts +++ b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot core.types + * @role The canonical TypeScript types describing an SDCPN document + */ + export type ID = string; export type ColorElementType = diff --git a/libs/@hashintel/petrinaut-core/src/validation/README.md b/libs/@hashintel/petrinaut-core/src/validation/README.md index 1ff45ab267f..f383896fb6c 100644 --- a/libs/@hashintel/petrinaut-core/src/validation/README.md +++ b/libs/@hashintel/petrinaut-core/src/validation/README.md @@ -1,3 +1,8 @@ +--- +layer: core.validation +role: Structural integrity validators for SDCPN entities, enforcing naming conventions +--- + # validation/ Structural integrity validators for SDCPN entities. These enforce naming diff --git a/libs/@hashintel/petrinaut-core/src/workers/README.md b/libs/@hashintel/petrinaut-core/src/workers/README.md new file mode 100644 index 00000000000..9e01e0793f2 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/workers/README.md @@ -0,0 +1,20 @@ +--- +layer: core.workers +role: The module entry points hosts instantiate as Web Workers +--- + +# Worker entry points + +One file per worker the host can spawn. Each is a thin entry point wiring a +message port to the runtime that does the work, holding no logic of its own, so +the runtimes stay testable on the main thread. + +| Entry point | Runtime it hosts | +| ---------------- | ----------------------------------- | +| `lsp.ts` | the language server | +| `simulation.ts` | frame computation for a single run | +| `monte-carlo.ts` | batched runs reporting only metrics | + +Separate export subpaths rather than one worker, so a host pays only for the +threads it uses — an editor with no experiments open never loads the Monte +Carlo runtime. diff --git a/libs/@hashintel/petrinaut/ARCHITECTURE.md b/libs/@hashintel/petrinaut/ARCHITECTURE.md index 5d5ce442d82..a3d88ab81d5 100644 --- a/libs/@hashintel/petrinaut/ARCHITECTURE.md +++ b/libs/@hashintel/petrinaut/ARCHITECTURE.md @@ -2,9 +2,9 @@ How `@hashintel/petrinaut`'s React layer consumes the headless simulation runtime from `@hashintel/petrinaut-core`. The core's architecture (engine, -frame format, worker protocol, Monte Carlo) is documented in -[`petrinaut-core/docs/architecture/`](../petrinaut-core/docs/architecture/index.html) -— this page covers only the React side of the boundary. +frame format, worker protocol, Monte Carlo) is documented in the architecture +docs, which #9206 adds a site for, and this page +covers only the React side of the boundary. > This file is internal engineering documentation. It deliberately does NOT > live in `docs/` — that folder is the end-user guide, consumed at runtime by diff --git a/libs/@hashintel/petrinaut/src/main.ts b/libs/@hashintel/petrinaut/src/main.ts index afd1269f459..05f4ca7d38f 100644 --- a/libs/@hashintel/petrinaut/src/main.ts +++ b/libs/@hashintel/petrinaut/src/main.ts @@ -1,3 +1,15 @@ +/** + * Public surface for `@hashintel/petrinaut` — the host-facing entry point. + * + * Re-exports the handful of contexts and types a host needs to embed the editor + * and inject its own capabilities (error tracking, optimization, slots). The + * editor itself is reached through `/ui`, and the React bindings through + * `/react`. + * + * @layerRoot petrinaut + * @role The host-facing entry point: the contexts and types an embedder wires up + */ + export type { ErrorTracker } from "./react/error-tracker-context"; export { ErrorTrackerContext } from "./react/error-tracker-context"; export type { PetrinautOptimization } from "./react/optimization-context"; diff --git a/libs/@hashintel/petrinaut/src/react/execution-frame/provider.tsx b/libs/@hashintel/petrinaut/src/react/execution-frame/provider.tsx index b44171305fa..4cf11ca1b3f 100644 --- a/libs/@hashintel/petrinaut/src/react/execution-frame/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/execution-frame/provider.tsx @@ -1,3 +1,8 @@ +/** + * @layerRoot react.execution-frame + * @role Abstracts where frames come from, so canvas and timeline work for live runs and recordings alike + */ + import { use, useState, type FC, type PropsWithChildren } from "react"; import { diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx index ed7aff3caa0..68513c80338 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx @@ -1,3 +1,8 @@ +/** + * @layerRoot react.experiments + * @role Tracks Monte Carlo experiment handles and their streamed metric results + */ + import { use, useEffect, useRef, useState } from "react"; import { v4 as generateUuid } from "uuid"; diff --git a/libs/@hashintel/petrinaut/src/react/hooks/index.ts b/libs/@hashintel/petrinaut/src/react/hooks/index.ts index 69fd67a3a96..bc7f78ec22e 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/index.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/index.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot react.hooks + * @role Cross-cutting hooks over the providers — documents, parameters, window lifecycle + */ + // Public hook surface for `@hashintel/petrinaut/react`. // // Each hook reads from an existing React context (SDCPN, Simulation, Playback, diff --git a/libs/@hashintel/petrinaut/src/react/index.ts b/libs/@hashintel/petrinaut/src/react/index.ts index 67ebe507cde..c134c7521a2 100644 --- a/libs/@hashintel/petrinaut/src/react/index.ts +++ b/libs/@hashintel/petrinaut/src/react/index.ts @@ -1,7 +1,12 @@ -// Public surface for `@hashintel/petrinaut/react` — React bindings. -// -// Hooks, contexts, and bridge providers that synchronize a Core instance with -// React. No visual widgets — `/ui` builds on top of this. +/** + * Public surface for `@hashintel/petrinaut/react` — React bindings. + * + * Hooks, contexts, and bridge providers that synchronize a Core instance with + * React. No visual widgets — `/ui` builds on top of this. + * + * @layerRoot react + * @role Contexts, hooks and providers that mirror core state into React + */ // --- Instance access + low-level adapters --- export { PetrinautInstanceContext } from "./instance-context"; diff --git a/libs/@hashintel/petrinaut/src/react/lsp/provider.tsx b/libs/@hashintel/petrinaut/src/react/lsp/provider.tsx index 0cf2e64836c..72d5cd1c03d 100644 --- a/libs/@hashintel/petrinaut/src/react/lsp/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/lsp/provider.tsx @@ -1,3 +1,8 @@ +/** + * @layerRoot react.lsp + * @role Exposes the core language client to the editor as React context + */ + import { use, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { diff --git a/libs/@hashintel/petrinaut/src/react/playback/README.md b/libs/@hashintel/petrinaut/src/react/playback/README.md index b3ad0e91cd7..21d78175571 100644 --- a/libs/@hashintel/petrinaut/src/react/playback/README.md +++ b/libs/@hashintel/petrinaut/src/react/playback/README.md @@ -1,3 +1,8 @@ +--- +layer: react.playback +role: Drives the viewed frame with a requestAnimationFrame loop and applies the per-mode ack policy +--- + # Playback Module React context for viewing simulation frames at controlled speeds. diff --git a/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx b/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx index fb3f4abb714..1c52de2a836 100644 --- a/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx @@ -1,3 +1,8 @@ +/** + * @layerRoot react.simulation + * @role Owns the run configuration and mirrors the core simulation handle into React + */ + import { use, useEffect, useRef, useState } from "react"; import { diff --git a/libs/@hashintel/petrinaut/src/react/state/README.md b/libs/@hashintel/petrinaut/src/react/state/README.md new file mode 100644 index 00000000000..043910eb86b --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/state/README.md @@ -0,0 +1,11 @@ +--- +layer: react.state +role: Contexts owning editor-session state — active net, selection, settings, undo/redo, read-only mode +--- + +State belonging to an editing session rather than to a document or a run. + +Read-only is the load-bearing piece: simulate mode locks editing except for an +allow-list, and `use-is-read-only` is the single source of that answer. +Components ask rather than working it out from mode flags, because a component +that guesses will eventually guess differently from its neighbour. diff --git a/libs/@hashintel/petrinaut/src/ui/index.ts b/libs/@hashintel/petrinaut/src/ui/index.ts index 9bbd5a12027..fa27bb932fe 100644 --- a/libs/@hashintel/petrinaut/src/ui/index.ts +++ b/libs/@hashintel/petrinaut/src/ui/index.ts @@ -1,8 +1,13 @@ -// Public surface for `@hashintel/petrinaut/ui` — the opinionated visual editor. -// -// `` is the single editor entry: it takes a -// `PetrinautDocHandle` and renders the full editor on top of -// `` (`/react`). +/** + * Public surface for `@hashintel/petrinaut/ui` — the opinionated visual editor. + * + * `` is the single editor entry: it takes a + * `PetrinautDocHandle` and renders the full editor on top of + * `` (`/react`). + * + * @layerRoot ui + * @role The visual editor: canvas, panels, dialogs and the Monaco integration + */ export { Petrinaut } from "./petrinaut"; export type { PetrinautAiMessage } from "./views/Editor/panels/ai-assistant-panel"; diff --git a/libs/@hashintel/petrinaut/src/ui/monaco/provider.tsx b/libs/@hashintel/petrinaut/src/ui/monaco/provider.tsx index 3fef0372d82..f654712d86b 100644 --- a/libs/@hashintel/petrinaut/src/ui/monaco/provider.tsx +++ b/libs/@hashintel/petrinaut/src/ui/monaco/provider.tsx @@ -1,3 +1,8 @@ +/** + * @layerRoot ui.monaco + * @role Wires the Monaco editor to the language server for authoring user code + */ + import { CompletionSync } from "./completion-sync"; import { MonacoContext } from "./context"; import { DiagnosticsSync } from "./diagnostics-sync"; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx index a32c21a68b0..1414f4da523 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx @@ -1,3 +1,8 @@ +/** + * @layerRoot ui.views.editor + * @role Arranges the panels, toolbars and dialogs around the canvas + */ + import { use, useState } from "react"; import { type MenuItem } from "@hashintel/ds-components"; diff --git a/libs/@hashintel/petrinaut/src/ui/views/README.md b/libs/@hashintel/petrinaut/src/ui/views/README.md new file mode 100644 index 00000000000..8293e3d2da6 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/README.md @@ -0,0 +1,8 @@ +--- +layer: ui.views +role: The top-level screens the editor composes — the editor shell and the net canvas +--- + +Each subfolder is a screen rather than a widget. The split matters because the +canvas is reused outside the full editor — Actual mode renders a net with no +editing affordances — so it cannot depend on the editor shell around it. diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx index 611d0eef4fe..c618b18fcfc 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx @@ -1,3 +1,8 @@ +/** + * @layerRoot ui.views.canvas + * @role Renders the net as an interactive graph, with node and arc interaction + */ + import "@xyflow/react/dist/style.css"; import { Background, ReactFlow, SelectionMode } from "@xyflow/react"; import { diff --git a/libs/@local/petrinaut-arch-docs/.gitignore b/libs/@local/petrinaut-arch-docs/.gitignore new file mode 100644 index 00000000000..5a2f7068984 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/.gitignore @@ -0,0 +1,9 @@ +# Build output. Regenerate with `mise run doc:architecture`. +# +# Not versioned: it is derived entirely from the annotations in the source and +# the authored pages in `content/`, so committing it would mean reviewing the +# same change twice and resolving conflicts in generated files. +bundle/ + +# Scratch copies from mutation-testing runs. Never part of the package. +.mut/ diff --git a/libs/@local/petrinaut-arch-docs/.oxlintrc.json b/libs/@local/petrinaut-arch-docs/.oxlintrc.json new file mode 100644 index 00000000000..979f705f360 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/.oxlintrc.json @@ -0,0 +1,35 @@ +{ + "$schema": "../../../node_modules/oxlint/configuration_schema.json", + "plugins": ["import", "unicorn", "typescript"], + "categories": { + "correctness": "error" + }, + "env": { + "builtin": true, + "es2026": true + }, + "rules": { + "default-case-last": "error", + "eqeqeq": ["error", "always", { "null": "ignore" }], + "no-console": "error", + "no-param-reassign": ["error", { "props": true }], + "no-shadow": "error", + "no-unused-vars": [ + "error", + { + "args": "all", + "argsIgnorePattern": "^_+", + "varsIgnorePattern": "^_+" + } + ], + "no-use-before-define": "error", + "import/no-cycle": "error", + "import/no-duplicates": "error", + "import/no-mutable-exports": "error", + "import/no-self-import": "error", + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-unsafe-function-type": "error", + "unicorn/filename-case": "error" + }, + "ignorePatterns": ["bundle/**", "content/**", ".turbo/**"] +} diff --git a/libs/@local/petrinaut-arch-docs/LICENSE-APACHE.md b/libs/@local/petrinaut-arch-docs/LICENSE-APACHE.md new file mode 100644 index 00000000000..4b43328a923 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/LICENSE-APACHE.md @@ -0,0 +1,189 @@ +# Apache License + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +- **(a)** You must give any other recipients of the Work or Derivative Works a copy of + this License; and +- **(b)** You must cause any modified files to carry prominent notices stating that You + changed the files; and +- **(c)** You must retain, in the Source form of any Derivative Works that You distribute, + all copyright, patent, trademark, and attribution notices from the Source form + of the Work, excluding those notices that do not pertain to any part of the + Derivative Works; and +- **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any + Derivative Works that You distribute must include a readable copy of the + attribution notices contained within such NOTICE file, excluding those notices + that do not pertain to any part of the Derivative Works, in at least one of the + following places: within a NOTICE text file distributed as part of the + Derivative Works; within the Source form or documentation, if provided along + with the Derivative Works; or, within a display generated by the Derivative + Works, if and wherever such third-party notices normally appear. The contents of + the NOTICE file are for informational purposes only and do not modify the + License. You may add Your own attribution notices within Derivative Works that + You distribute, alongside or as an addendum to the NOTICE text from the Work, + provided that such additional attribution notices cannot be construed as + modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: Apply the Apache License to a specific file + +To apply the Apache License to an individual file, attach the following notice. +The text should be enclosed in the appropriate comment syntax for the file +format. + + Copyright © 2025–, HASH + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/libs/@local/petrinaut-arch-docs/LICENSE-MIT.md b/libs/@local/petrinaut-arch-docs/LICENSE-MIT.md new file mode 100644 index 00000000000..d85585ee20d --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/LICENSE-MIT.md @@ -0,0 +1,21 @@ +# MIT License + +Copyright © 2025–, HASH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libs/@local/petrinaut-arch-docs/LICENSE.md b/libs/@local/petrinaut-arch-docs/LICENSE.md new file mode 100644 index 00000000000..6dad94c0e5a --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/LICENSE.md @@ -0,0 +1,3 @@ +# License + +Licensed under either of the [Apache License, Version 2.0](LICENSE-APACHE.md) or [MIT license](LICENSE-MIT.md) at your option. diff --git a/libs/@local/petrinaut-arch-docs/README.md b/libs/@local/petrinaut-arch-docs/README.md new file mode 100644 index 00000000000..34a9f52e476 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/README.md @@ -0,0 +1,285 @@ +# `@local/petrinaut-arch-docs` + +Generates the Petrinaut architecture documentation from annotations in the +source, and bundles it with hand-written MDX into one portable artefact. + +```sh +# Regenerate the bundle after changing annotations or code +yarn workspace @local/petrinaut-arch-docs doc:architecture + +# Check the annotations without writing anything +yarn workspace @local/petrinaut-arch-docs lint:arch-docs +``` + +## Why this exists + +Architecture docs rot because nothing fails when they stop being true. Two +specific failures motivated this package: + +- `petrinaut-core/scripts/generate-dependency-diagrams.mjs` held the architecture + as ~180 lines of `if (path.startsWith("simulation/monte-carlo/"))` mappings, + far from the code they described, 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` + were missing from the diagram entirely. +- `petrinaut-core/docs/architecture/*.html` was 3,100 lines of hand-written HTML + that nothing verified against the code. #9205 moves its content into authored + MDX under `content/simulation/`, where it sits beside the generated pages. + +Here the architecture is declared next to the code it describes, and the build fails +when a declaration stops matching reality. + +## Declaring a layer + +A declaration is two lines — an id and a one-line role: + +```ts +/** + * @layerRoot core.simulation.monte-carlo + * @role Runs many simulations with bounded frame memory + */ +``` + +That is the whole vocabulary. `@layerRoot` names the layer this 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 these docs assert — anything +more would be a claim the generator cannot check. + +Tags are read from any block comment, only at the start of a line, so mentioning +`@layerRoot` in prose declares nothing. A tag's text wraps across lines until the +next tag or a blank line. Any other tag is ignored — `@param`, `@deprecated` +and the rest are someone else's business — except a miscasing of one of these +two, which is reported as a probable typo. + +### Declaring from a README instead + +A folder `README.md` can declare the same thing in frontmatter, and its prose +becomes the layer's page body — so a folder README that already explains itself +becomes an architecture page for free. Use it when the folder has real prose to +carry, or when no single file is the obvious host. Otherwise prefer the doc +comment: it needs no new file. + +```yaml +--- +layer: core.simulation.monte-carlo +role: Runs many simulations with bounded frame memory +--- +``` + +`layer` and `role` are the only keys, and an unknown one alongside them fails +the build rather than being ignored — a misspelled `role` would otherwise leave +the layer with no responsibility statement and no complaint. + +Use one or the other on a folder, never both. A README with no `layer` key is +left alone as an ordinary document, and is linked from its layer page under +"Further reading". + +Layer ids are dotted and hierarchical, and every ancestor must itself be +declared — `core.simulation.monte-carlo` requires `core.simulation` and `core`. + +### Inheritance is what keeps this small + +A file with no tags belongs to the nearest ancestor folder that declares a layer. +That is why 37 declarations cover 413 files: you declare a layer where the +architecture actually changes, not on every file. + +## The output: a portable bundle + +Written to `bundle/`, which is **git-ignored build output** — it is derived +entirely from the annotations and from `content/`, so committing it would mean +reviewing the same change twice and resolving conflicts in generated files. +Regenerate it whenever you need it; nothing depends on a stored copy. + +The bundle is framework-neutral by design — the Starlight site in +`apps/petrinaut-docs` and hash.dev are both just consumers. + +| File | What it is | +| ------------------- | ---------------------------------------------------------------- | +| `architecture.json` | The model: layers, edges, enforced rules | +| `architecture.md` | The whole architecture as one file — the cheapest read for an AI | +| `manifest.json` | Page tree for building navigation without crawling `pages/` | +| `pages/**.mdx` | Generated layer pages, plus authored pages merged in | +| `components/*.tsx` | React diagram components imported by authored pages | +| `diagrams/**.d2` | Diagram sources (diffable) | +| `diagrams/**.svg` | Rendered diagrams | + +**Generated** MDX is YAML frontmatter plus plain CommonMark — no JSX, no +imports, no framework components — which is what lets it render in Astro, in +hash.dev's Next.js MDX pipeline, and as plain text. + +**Authored** pages may additionally import the diagram components below, which is +where every requirement the bundle places on a host comes from: + +| A host must provide | For | +| ---------------------------- | -------------------------------------------------- | +| A React-capable MDX pipeline | Any authored page that imports a diagram component | + +Nothing else — in particular nothing hydrates, so no client-side runtime is +required. In particular the bundle asks for **no Markdown or Rehype +plugins** — a host renders it with its Markdown pipeline exactly as configured, +which is what keeps "render the bundle" a small job rather than a negotiation. + +### Embedding the bundle elsewhere + +A host reads `manifest.json`, maps each page's `slug` onto its own URL space, and +renders `pages/`. + +One contract to honour: **links between generated pages are relative and assume +slugs map to URLs without a trailing slash.** A host that serves +`/architecture/core/simulation/` rather than `/architecture/core/simulation` +must rewrite them; `manifest.json` gives you every slug to do so. The Starlight +consumer sets `trailingSlash: "never"` for this reason. + +## Diagrams + +Diagrams are D2, rendered to SVG at build time. Three kinds, each bounding its +node count a different way, because a node-link diagram stops being readable +somewhere around twenty boxes: + +| Diagram | Shows | Bounded by | +| ------------- | ----------------------------------------------- | ------------------ | +| `overview` | The top-level layers | Roots | +| `around/` | What a layer depends on, and what depends on it | The layer's degree | +| `within/` | A layer's direct children | Its fan-out | + +Every layer gets an `around/` diagram, leaves included — those are where readers +land, and "what does this touch" is the question they arrive with. Only layers +with sub-layers get a `within/` one. + +A neighbourhood draws only edges _incident to the focus_. Edges among the +neighbours are real but belong to those layers' own pages; drawing them rebuilds +the tangle the overview exists to avoid. + +Aggregation never invents a dependency: an edge appears because imports exist, +and its count sums real `fileDependencies`. Neighbours are capped at twelve, and +the remainder becomes a single dashed "+N further layers" node carrying their +combined count — elided where it would be unreadable, never dropped where it +would read as absent. + +Names are namespaced by directory rather than by prefix: a layer id is unique +only among layer ids, so a flat `around-` would collide with a top-level +layer actually called `around-something`. + +## Hand-written pages (optional) + +`content/` is entirely optional. With no `content/` directory at all, the +generator emits a bundle of generated pages only, and the docs site renders it — +`/` redirects to the generated overview instead of an authored home page. Add +pages when you have something to say that an import graph cannot express; delete +them freely. + +Anything in `content/` is copied into the bundle and merged into the same +manifest as the generated pages. Slugs mirror the directory layout; `title`, +`description` and `sidebar_order` come from frontmatter. + +### Attaching a page to a layer + +By default an authored page sits at the top level, as a standalone narrative +entry. Add `attachTo` and it moves _inside_ the generated tree instead, beneath +the page for the layer it explains: + +```yaml +--- +title: Memory model +description: Where simulation state actually lives. +attachTo: core.simulation # a layer declared in the source +sidebar_order: 10 +--- +``` + +The page's slug becomes `architecture/core/simulation/memory-model`, the layer's +page gains a **Guides** section linking to it, and any host that nests by slug +shows the guide beside the generated reference for the same code. + +`attachTo` is not a layer declaration — it references a layer declared in a +package, and the build fails if that layer does not exist. Declaring layers from +`content/` remains forbidden. + +Generated pages occupy `sidebar_order` 1000 and above, so within a layer the +attached guides (low numbers) sort ahead of its sub-layers. + +### Diagram components + +`content/components/` holds React components that authored pages import: + +```mdx +import { ByteMap } from "@diagrams/byte-map"; + + +``` + +The `@diagrams/` alias 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`. +The components ship _inside_ the bundle (`components/`), so a host renders them +from the artefact rather than needing its own copy. + +Two rules keep them portable, and both are load-bearing: + +- **Plain React, no dependencies.** No design system, no Astro, no `next/*`. + Styling lives in `components/diagram.css`, which derives its colours from the + host's `currentColor` so it works on light and dark themes it has never seen. +- **String props, never JSX.** JSX written inside MDX is compiled by the _host's_ + MDX renderer, and passing that to a React component fails at render. Props are + strings, and `` `backticks` `` render as ``. + +This is the one place the bundle asks something of its host: rendering these +pages needs a React-capable MDX pipeline. Generated pages remain plain +CommonMark and need nothing, and `architecture.md` — the single-file artefact +for agents — contains no components at all. + +### Linking between pages + +Because `attachTo` decides where a page ends up, an authored page cannot know its +own depth and so cannot write a correct relative link by hand. Name the target +instead, and the generator computes the path: + +| Syntax | Resolves to | +| -------------------------------------- | --------------------------------------------------- | +| `[text](layer:core.simulation.engine)` | that layer's generated page | +| `[text](doc:simulation/memory-model)` | another authored page, by its path under `content/` | + +Fragments are preserved (`layer:core.hir#sub-layers`). A target that does not +resolve is a build error rather than a broken link nobody notices. Ordinary +relative and absolute links are left untouched, so a top-level page that will +never move can still use them. + +Authored pages carry the reasoning no import graph can supply: why a boundary is +where it is, and what was tried before. They may not declare a layer; layer +declarations belong in the packages. + +## What the checks enforce + +These run on every build of the bundle: `doc:architecture` refuses to write while +any of them fails. `lint:arch-docs` runs the same checks and reports without +writing, which is the form to reach for in a pre-commit hook or a CI step. Either +fails on: + +- a source file that no declaration covers +- a source file the import graph reached that no layer claims, which means the + extractor and the graph disagree about what is in scope +- a layer whose dotted id implies an undeclared ancestor +- a duplicate layer id, or two declarations on one folder +- a duplicated singular tag, a tag with no value, or an unknown key in a + declaring README +- a package configured for a language with no extractor +- an `exports` subpath with no resolvable source entry, since imports through it + would be missing from the graph +- a rule naming a layer that does not exist, which would leave it inert +- a dependency violating a rule in `architecture.config.ts` +- an `attachTo` naming a layer that does not exist +- a `layer:` or `doc:` link target that does not resolve +- an `@diagrams/` import naming a component that does not exist + +The last four in the graph group exist because each failure removes coverage +rather than adding a visible error. An `exports` subpath that stops resolving, or +a rule with a typo, leaves a build that passes while checking less than it +claims. + +Warnings (reported, non-fatal): a layer with no files and no sub-layers, an +`exports` subpath with no resolvable source entry. + +## Adding a package + +Add it to `packages` in `architecture.config.ts`, declare a root layer in its +source, and run a build. `sourceDirectory` defaults to `src`, so build +configuration outside it is deliberately not part of any layer. diff --git a/libs/@local/petrinaut-arch-docs/architecture.config.ts b/libs/@local/petrinaut-arch-docs/architecture.config.ts new file mode 100644 index 00000000000..9f7c183b23b --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/architecture.config.ts @@ -0,0 +1,107 @@ +/** + * What the architecture bundle covers, and what it forbids. + * + * This file holds only things that genuinely cannot be read from the code: + * which packages participate, what to skip, and the layer-crossing rules. The + * layer taxonomy itself deliberately lives *in* the packages, as README + * frontmatter and `@layerRoot` annotations — that is the whole point of the + * exercise. Resist the temptation to add a path→layer mapping here. + */ + +import type { ArchitecturePackageInput } from "./src/model"; + +export interface LayerRule { + /** Layer id or ancestor prefix the rule applies to. */ + from: string; + /** Layer id or ancestor prefix that must not be reached. */ + to: string; + reason: string; +} + +export interface ArchitectureConfig { + packages: ArchitecturePackageInput[]; + rules: LayerRule[]; + ignoredDirectories: string[]; + ignoredFilePattern: RegExp; + /** Where the generated bundle is written, repo-relative. */ + outputDirectory: string; + /** Where hand-written MDX is read from, repo-relative. */ + contentDirectory: string; + /** Base URL for source links in generated pages. */ + sourceUrlPrefix: string; +} + +export const config: ArchitectureConfig = { + packages: [ + { + name: "@hashintel/petrinaut-core", + path: "libs/@hashintel/petrinaut-core", + description: + "Headless SDCPN engine: document model, HIR compiler, simulation runtimes, LSP. No React, no DOM.", + language: "typescript", + }, + { + name: "@hashintel/petrinaut", + path: "libs/@hashintel/petrinaut", + description: + "React editor built on the headless core: providers, canvas, panels, Monaco integration.", + language: "typescript", + }, + ], + + /** + * Each rule is a claim the drift check enforces against the real import + * graph, so adding one here without fixing the code fails CI. A rule matches + * an edge when the edge's endpoints are the named layer or a descendant of it. + */ + rules: [ + { + from: "core", + to: "react", + reason: + "the headless core is published without React and must stay usable from Node and workers", + }, + { + from: "core", + to: "ui", + reason: "the headless core must not reach into editor components", + }, + { + from: "core", + to: "petrinaut", + reason: + "the core is the lower package of the pair and cannot depend on its consumer", + }, + { + from: "react", + to: "ui", + reason: + "state providers must not depend on the components that render them, so the React layer stays testable without mounting the editor", + }, + ], + + ignoredDirectories: [ + "node_modules", + "dist", + "__fixtures__", + "__snapshots__", + "docs", + ], + + /** + * Tests, stories and ambient declarations are excluded: they describe the + * architecture's *use*, not its shape, and including them inflates every + * layer's file count with fixtures. + */ + ignoredFilePattern: + /(?:\.(?:test|spec|stories)\.[cm]?[jt]sx?$|\.d\.ts$|\/CHANGELOG\.md$|\/LICENSE[^/]*\.md$)/u, + + /** + * The bundle is the product; the Starlight site in `apps/petrinaut-docs` and + * hash.dev are both just consumers of it. It therefore lives with the + * generator that owns its schema, not inside either renderer. + */ + outputDirectory: "libs/@local/petrinaut-arch-docs/bundle", + contentDirectory: "libs/@local/petrinaut-arch-docs/content", + sourceUrlPrefix: "https://github.com/hashintel/hash/blob/main/", +}; diff --git a/libs/@hashintel/petrinaut-core/dependency-cruiser.tsconfig.json b/libs/@local/petrinaut-arch-docs/dependency-cruiser.tsconfig.json similarity index 54% rename from libs/@hashintel/petrinaut-core/dependency-cruiser.tsconfig.json rename to libs/@local/petrinaut-arch-docs/dependency-cruiser.tsconfig.json index cbff533fc46..afe5f0c20e9 100644 --- a/libs/@hashintel/petrinaut-core/dependency-cruiser.tsconfig.json +++ b/libs/@local/petrinaut-arch-docs/dependency-cruiser.tsconfig.json @@ -1,6 +1,7 @@ { + "//": "Used only by the architecture generator, to give dependency-cruiser's TypeScript resolver the workspace path mappings. Moved here from petrinaut-core when the hand-written diagram script was retired, so the config lives with its only consumer.", "compilerOptions": { - "baseUrl": "../../../..", + "baseUrl": "../../..", "paths": { "@hashintel/petrinaut-core": [ "libs/@hashintel/petrinaut-core/src/index.ts" @@ -14,6 +15,15 @@ "@hashintel/petrinaut-core/hir-runtime": [ "libs/@hashintel/petrinaut-core/src/hir-runtime.ts" ], + "@hashintel/petrinaut-core/ai": [ + "libs/@hashintel/petrinaut-core/src/ai.ts" + ], + "@hashintel/petrinaut-core/compiled-model": [ + "libs/@hashintel/petrinaut-core/src/compiled-model.ts" + ], + "@hashintel/petrinaut-core/optimization": [ + "libs/@hashintel/petrinaut-core/src/optimization.ts" + ], "@hashintel/petrinaut-core/workers/lsp": [ "libs/@hashintel/petrinaut-core/src/workers/lsp.ts" ], @@ -25,5 +35,8 @@ ] } }, - "include": ["src", "../petrinaut/src"] + "include": [ + "../../@hashintel/petrinaut-core/src", + "../../@hashintel/petrinaut/src" + ] } diff --git a/libs/@local/petrinaut-arch-docs/package.json b/libs/@local/petrinaut-arch-docs/package.json new file mode 100644 index 00000000000..a9acd4f5aaf --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/package.json @@ -0,0 +1,38 @@ +{ + "name": "@local/petrinaut-arch-docs", + "version": "0.0.0-private", + "private": true, + "description": "Generates the Petrinaut architecture bundle from in-code annotations", + "license": "(MIT OR Apache-2.0)", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "doc:architecture": "tsx src/cli.ts build", + "fix:eslint": "oxlint --fix --report-unused-disable-directives-severity=error .", + "lint:arch-docs": "tsx src/cli.ts check", + "lint:eslint": "oxlint --report-unused-disable-directives-severity=error .", + "lint:tsc": "tsc --noEmit", + "test:unit": "vitest --run src" + }, + "dependencies": { + "dependency-cruiser": "18.0.0", + "js-yaml": "4.3.1", + "zod": "4.4.3" + }, + "devDependencies": { + "@hashintel/petrinaut": "workspace:*", + "@hashintel/petrinaut-core": "workspace:*", + "@local/tsconfig": "workspace:*", + "@types/js-yaml": "^4", + "@types/node": "22.18.13", + "oxlint": "1.63.0", + "tsx": "4.20.6", + "typescript": "5.9.3", + "vitest": "4.1.10" + } +} diff --git a/libs/@local/petrinaut-arch-docs/src/build.ts b/libs/@local/petrinaut-arch-docs/src/build.ts new file mode 100644 index 00000000000..1fe89f8dda9 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/build.ts @@ -0,0 +1,356 @@ +/** + * Builds the bundle in memory. + * + * Kept separate from the CLI so `build` and `check` run the same pipeline — + * `check` builds and throws the files away, reporting only the diagnostics. + */ + +import { join } from "node:path"; + +import { config, type ArchitectureConfig } from "../architecture.config"; +import { runChecks } from "./check"; +import { + collectAuthoredContent, + type AuthoredComponent, + type AuthoredPage, +} from "./content"; +import { error, type Diagnostic } from "./diagnostics"; +import { + buildManifest, + buildSingleFileArchitecture, + type BundleManifest, +} from "./emit/bundle-outputs"; +import { + buildNeighbourhoodDiagram, + buildOverviewDiagram, + buildSubtreeDiagram, +} from "./emit/d2"; +import { + buildPages, + resolveAuthoredLinks, + resolveComponentImports, + layerSlug, + type GeneratedPage, +} from "./emit/mdx"; +import { extract } from "./extract"; +import { buildGraph } from "./graph"; +import { + architectureModelSchema, + packageSchema, + type ArchitectureModel, +} from "./model"; + +export const GENERATOR_NAME = "@local/petrinaut-arch-docs"; + +export interface BuiltBundle { + model: ArchitectureModel; + manifest: BundleManifest; + generated: GeneratedPage[]; + authored: AuthoredPage[]; + /** Diagram name → D2 source. */ + diagramSources: Map; + /** Importable diagram components shipped with the bundle. */ + components: AuthoredComponent[]; + singleFile: string; + diagnostics: Diagnostic[]; +} + +/** Diagram embedded on the overview page. */ +const OVERVIEW_DIAGRAM = "overview"; + +/** + * Diagram names are namespaced by subdirectory rather than by a name prefix, + * because a layer id is only guaranteed unique among layer ids — a flat + * `around-${id}` scheme would collide with a top-level layer actually called + * `around-something`. + */ +const NEIGHBOURHOOD_PREFIX = "around"; +const SUBTREE_PREFIX = "within"; + +export const buildBundle = async (options: { + repoRoot: string; + overrides?: Partial; + /** + * Whether rendered SVGs will exist. When false, pages omit diagram images + * rather than pointing at files that were never written — a bundle that + * references a missing image fails the consuming site's build. + */ + includeDiagrams?: boolean; +}): Promise => { + const settings: ArchitectureConfig = { ...config, ...options.overrides }; + const { repoRoot } = options; + + // Normalise once up front so every stage sees defaulted fields such as + // `sourceDirectory` rather than each having to reapply them. + const packages = settings.packages.map((pkg) => packageSchema.parse(pkg)); + + const extraction = await extract({ + repoRoot, + packages, + ignoredDirectories: settings.ignoredDirectories, + ignoredFilePattern: settings.ignoredFilePattern, + }); + + const diagnostics: Diagnostic[] = [...extraction.diagnostics]; + + const graph = await buildGraph({ + repoRoot, + packages: packages.filter((pkg) => pkg.language === "typescript"), + tsconfigPath: join( + repoRoot, + "libs/@local/petrinaut-arch-docs/dependency-cruiser.tsconfig.json", + ), + ignoredDirectories: settings.ignoredDirectories, + ignoredFilePattern: settings.ignoredFilePattern, + fileLayers: extraction.fileLayers, + layers: extraction.layers, + }); + + diagnostics.push(...graph.diagnostics); + + const model: ArchitectureModel = { + version: 1, + packages, + layers: extraction.layers, + edges: graph.edges, + rules: settings.rules, + }; + + // Validated, not enforced. `extract` already reports each invalid layer + // against the file that declared it, which is the message someone can act on; + // throwing here would replace it with a stack trace. Anything the per-layer + // pass missed still gets reported rather than reaching a consumer, and the + // build refuses to write while any error stands. + const validated = architectureModelSchema.safeParse(model); + if (!validated.success) { + for (const issue of validated.error.issues) { + // Layer issues are already reported against the file that declared them. + // Repeating them here would print the same typo three times. + if (issue.path[0] === "layers") { + continue; + } + + diagnostics.push( + error( + "architecture.config.ts", + `the generated model is not valid at \`${issue.path.join(".")}\`: ${issue.message}`, + ), + ); + } + } + + diagnostics.push(...runChecks({ model, rules: settings.rules })); + + const diagramSources = new Map(); + + diagramSources.set( + OVERVIEW_DIAGRAM, + buildOverviewDiagram(model.layers, model.edges, GENERATOR_NAME), + ); + + // A neighbourhood diagram for *every* layer, including leaves — those are + // where a reader most often lands, and "what does this touch" is the question + // they arrive with. + for (const layer of model.layers) { + diagramSources.set( + `${NEIGHBOURHOOD_PREFIX}/${layer.id}`, + buildNeighbourhoodDiagram( + layer.id, + model.layers, + model.edges, + GENERATOR_NAME, + ), + ); + } + + // A drill-down diagram for every layer that has sub-layers, answering the + // other question: what is inside this one. + const parentLayerIds = model.layers + .filter((layer) => model.layers.some((other) => other.parent === layer.id)) + .map((layer) => layer.id); + + for (const parentId of parentLayerIds) { + diagramSources.set( + `${SUBTREE_PREFIX}/${parentId}`, + buildSubtreeDiagram(parentId, model.layers, model.edges, GENERATOR_NAME), + ); + } + + const authoredResult = await collectAuthoredContent({ + repoRoot, + contentDirectory: settings.contentDirectory, + }); + + for (const failure of authoredResult.errors) { + diagnostics.push(error(failure.file, failure.message)); + } + + const declaredLayerIds = new Set(model.layers.map((layer) => layer.id)); + const layerSlugsById = new Map( + model.layers.map((layer) => [layer.id, layerSlug(layer.id)]), + ); + + /** + * Resolve every authored page's final slug in one pass. + * + * An attached page moves beneath its layer's page so the guide and the + * generated reference for the same code sit together. Its file name becomes + * the last segment, which is why two guides attached to one layer must not + * share a file name. A page whose `attachTo` does not name a real layer is + * reported and stays where it was, so one bad guide does not move the rest. + */ + const authoredSlugs = new Map(); + const guidesByLayer = new Map< + string, + { slug: string; title: string; description: string }[] + >(); + + for (const page of authoredResult.pages) { + const attached = + page.attachTo !== null && declaredLayerIds.has(page.attachTo); + + if (page.attachTo !== null && !attached) { + diagnostics.push( + error( + page.sourceFile, + `attachTo \`${page.attachTo}\` is not a declared layer`, + ), + ); + } + + if (!attached || page.attachTo === null) { + authoredSlugs.set(page.slug, page.slug); + continue; + } + + const leaf = page.slug.slice(page.slug.lastIndexOf("/") + 1); + const slug = `${layerSlug(page.attachTo)}/${leaf}`; + authoredSlugs.set(page.slug, slug); + guidesByLayer.set(page.attachTo, [ + ...(guidesByLayer.get(page.attachTo) ?? []), + { slug, title: page.title, description: page.description }, + ]); + } + + const componentNames = new Set( + authoredResult.components.map((component) => component.name), + ); + + // Authored pages address their targets by name (`layer:`, `doc:`, `@diagrams/`) + // rather than by path, because a page's depth is only known once `attachTo` is + // resolved. + const authored = authoredResult.pages.map((page) => { + const slug = authoredSlugs.get(page.slug) ?? page.slug; + const linked = resolveAuthoredLinks(page.contents, slug, { + layerSlugs: layerSlugsById, + docSlugs: authoredSlugs, + }); + const imported = resolveComponentImports( + linked.contents, + slug, + componentNames, + ); + + for (const target of [...linked.unresolved, ...imported.unresolved]) { + diagnostics.push( + error(page.sourceFile, `link target \`${target}\` does not resolve`), + ); + } + + return { + ...page, + slug, + path: `pages/${slug}.mdx`, + contents: imported.contents, + }; + }); + + const includeDiagrams = options.includeDiagrams ?? true; + + const generated = buildPages(model, { + sourceUrlPrefix: settings.sourceUrlPrefix, + overviewDiagram: includeDiagrams ? OVERVIEW_DIAGRAM : null, + neighbourhoodDiagrams: includeDiagrams + ? new Map( + model.layers.map((layer) => [ + layer.id, + `${NEIGHBOURHOOD_PREFIX}/${layer.id}`, + ]), + ) + : new Map(), + subtreeDiagrams: includeDiagrams + ? new Map(parentLayerIds.map((id) => [id, `${SUBTREE_PREFIX}/${id}`])) + : new Map(), + guidesByLayer, + }); + + /** + * Checked against generated pages and against other authored pages. + * + * An attached page takes its last slug segment from its own file name, so + * `content/a/protocol.mdx` and `content/b/protocol.mdx` attached to the same + * layer resolve to one page. Checking only against generated pages let one + * overwrite the other while the manifest listed both. + */ + const occupiedSlugs = new Set(generated.map((page) => page.slug)); + for (const page of authored) { + if (occupiedSlugs.has(page.slug)) { + diagnostics.push( + error( + page.sourceFile, + `page slug \`${page.slug}\` is already taken; rename this file or attach it to a different layer`, + ), + ); + continue; + } + occupiedSlugs.add(page.slug); + } + + const manifest = buildManifest({ + generator: GENERATOR_NAME, + generated, + authored, + }); + + return { + model, + manifest, + generated, + authored, + diagramSources, + singleFile: buildSingleFileArchitecture(model), + components: authoredResult.components, + diagnostics, + }; +}; + +/** + * The bundle's text files, keyed by path relative to the bundle root. SVGs are + * excluded: they are produced by the external `d2` renderer, so comparing them + * would make drift detection depend on the renderer's exact version. + */ +export const bundleTextFiles = (bundle: BuiltBundle): Map => { + const files = new Map(); + + files.set("architecture.json", `${JSON.stringify(bundle.model, null, 2)}\n`); + files.set("manifest.json", `${JSON.stringify(bundle.manifest, null, 2)}\n`); + files.set("architecture.md", bundle.singleFile); + + for (const [name, source] of bundle.diagramSources) { + files.set(`diagrams/${name}.d2`, source); + } + + for (const page of bundle.generated) { + files.set(page.path, page.contents); + } + + for (const page of bundle.authored) { + files.set(page.path, page.contents); + } + + for (const component of bundle.components) { + files.set(component.path, component.contents); + } + + return files; +}; diff --git a/libs/@local/petrinaut-arch-docs/src/check.test.ts b/libs/@local/petrinaut-arch-docs/src/check.test.ts new file mode 100644 index 00000000000..f7eb59abc79 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/check.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "vitest"; + +import { + checkAncestorsDeclared, + checkEmptyLayers, + checkRuleTargets, + checkRules, + withinScope, +} from "./check"; + +import type { ArchitectureModel, Edge, Layer } from "./model"; + +/** + * These are the checks a build fails on, so each one is pinned in both directions: + * it fires when the rule is broken, and stays quiet when it is not. A check that + * silently stops firing is worse than no check, because the docs keep claiming to + * be verified. + */ + +const layer = (overrides: Partial & Pick): Layer => ({ + name: overrides.id, + parent: + overrides.id.lastIndexOf(".") === -1 + ? null + : overrides.id.slice(0, overrides.id.lastIndexOf(".")), + package: "@test/pkg", + role: "role", + declaredIn: `src/${overrides.id}/README.md`, + prose: null, + references: [], + files: ["src/a.ts"], + fileCount: 1, + lineCount: 1, + ...overrides, +}); + +const edge = (from: string, to: string, count = 1): Edge => ({ + from, + to, + fileDependencies: count, + examples: [{ from: `src/${from}.ts`, to: `src/${to}.ts` }], + crossesPackage: false, +}); + +const model = (layers: Layer[], edges: Edge[] = []): ArchitectureModel => ({ + version: 1, + packages: [], + layers, + edges, + rules: [], +}); + +describe("withinScope", () => { + it("matches the scope itself and its descendants", () => { + expect(withinScope("core", "core")).toBe(true); + expect(withinScope("core.simulation", "core")).toBe(true); + expect(withinScope("core.simulation.engine", "core")).toBe(true); + }); + + it("does not match a sibling that merely shares a prefix", () => { + expect(withinScope("coreutils", "core")).toBe(false); + expect(withinScope("react", "core")).toBe(false); + }); +}); + +describe("checkAncestorsDeclared", () => { + it("reports an implied ancestor that nobody declared", () => { + const diagnostics = checkAncestorsDeclared( + model([layer({ id: "core" }), layer({ id: "core.a.b" })]), + ); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toContain("`core.a`"); + expect(diagnostics[0]?.severity).toBe("error"); + }); + + it("stays quiet when the whole chain is declared", () => { + const diagnostics = checkAncestorsDeclared( + model([ + layer({ id: "core" }), + layer({ id: "core.a" }), + layer({ id: "core.a.b" }), + ]), + ); + + expect(diagnostics).toEqual([]); + }); +}); + +describe("checkRuleTargets", () => { + const layers = [layer({ id: "core" }), layer({ id: "ui" })]; + + it("reports a rule naming a layer that does not exist", () => { + const diagnostics = checkRuleTargets(model(layers), [ + { from: "reactt", to: "ui", reason: "typo in from" }, + ]); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toContain("`reactt`"); + expect(diagnostics[0]?.message).toContain("can never fire"); + expect(diagnostics[0]?.file).toBe("architecture.config.ts"); + }); + + it("reports both sides when both are wrong", () => { + const diagnostics = checkRuleTargets(model(layers), [ + { from: "nope", to: "also-nope", reason: "two typos" }, + ]); + + expect(diagnostics).toHaveLength(2); + }); + + it("stays quiet when both sides name declared layers", () => { + const diagnostics = checkRuleTargets(model(layers), [ + { from: "core", to: "ui", reason: "core stays headless" }, + ]); + + expect(diagnostics).toEqual([]); + }); + + /** + * A rule states its endpoints as layer ids, and `checkRules` widens each to + * cover descendants. An ancestor prefix that is not itself declared would + * therefore match nothing, which is the case this rejects. + */ + it("rejects a prefix that is not itself a declared layer", () => { + const diagnostics = checkRuleTargets( + model([layer({ id: "core" }), layer({ id: "core.engine" })]), + [{ from: "core.eng", to: "core", reason: "partial segment" }], + ); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toContain("`core.eng`"); + }); +}); + +describe("checkRules", () => { + const layers = [ + layer({ id: "core" }), + layer({ id: "core.engine" }), + layer({ id: "ui" }), + layer({ id: "ui.views" }), + ]; + + it("fires when a descendant violates a rule stated at the parent", () => { + const diagnostics = checkRules( + model(layers, [edge("core.engine", "ui.views", 4)]), + [{ from: "core", to: "ui", reason: "core stays headless" }], + ); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toContain("core stays headless"); + expect(diagnostics[0]?.message).toContain("4 imports"); + // Names a real file so the fix does not begin with a search. + expect(diagnostics[0]?.file).toBe("src/core.engine.ts"); + }); + + it("stays quiet on the permitted direction", () => { + const diagnostics = checkRules( + model(layers, [edge("ui.views", "core.engine")]), + [{ from: "core", to: "ui", reason: "core stays headless" }], + ); + + expect(diagnostics).toEqual([]); + }); + + it("uses singular wording for a single import", () => { + const diagnostics = checkRules(model(layers, [edge("core", "ui", 1)]), [ + { from: "core", to: "ui", reason: "because" }, + ]); + + expect(diagnostics[0]?.message).toContain("1 import does"); + }); +}); + +describe("checkEmptyLayers", () => { + it("warns about a leaf layer with no files", () => { + const diagnostics = checkEmptyLayers( + model([layer({ id: "core", files: [], fileCount: 0 })]), + ); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.severity).toBe("warning"); + }); + + it("allows a grouping layer to hold no files of its own", () => { + const diagnostics = checkEmptyLayers( + model([ + layer({ id: "core", files: [], fileCount: 0 }), + layer({ id: "core.engine" }), + ]), + ); + + expect(diagnostics).toEqual([]); + }); +}); diff --git a/libs/@local/petrinaut-arch-docs/src/check.ts b/libs/@local/petrinaut-arch-docs/src/check.ts new file mode 100644 index 00000000000..9936756d341 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/check.ts @@ -0,0 +1,137 @@ +/** + * Checks that turn the documentation into something a build can hold to account. + * + * Documentation rots because nothing fails when it stops being true. Each check + * here makes one class of rot into a build error: a layer id implying an + * ancestor nobody declared, a rule the real import graph violates, a + * declaration whose folder has been emptied. + * + * Every check states something about the graph, which is all this version + * asserts. It holds no unverifiable prose to account because it reads none. + * + * Several checks exist because their failure removes coverage instead of + * producing an error. A rule with a typo, or an `exports` subpath that stops + * resolving, leaves a build that passes while verifying less than it claims. + */ + +import { error, warning, type Diagnostic } from "./diagnostics"; +import { ancestorLayerIds } from "./model"; + +import type { LayerRule } from "../architecture.config"; +import type { ArchitectureModel } from "./model"; + +/** True when `layerId` is the scope itself or nested inside it. */ +export const withinScope = (layerId: string, scope: string): boolean => + layerId === scope || layerId.startsWith(`${scope}.`); + +/** + * Every ancestor segment of a dotted layer id must itself be a declared layer. + * + * Without this, `core.simulation.monte-carlo` could exist while + * `core.simulation` does not, leaving the diagram with a container nobody + * described and the docs with a page that 404s from its own breadcrumb. + */ +export const checkAncestorsDeclared = ( + model: ArchitectureModel, +): Diagnostic[] => { + const declared = new Set(model.layers.map((layer) => layer.id)); + + return model.layers.flatMap((layer) => + ancestorLayerIds(layer.id) + .filter((ancestor) => !declared.has(ancestor)) + .map((ancestor) => + error( + layer.declaredIn, + `layer \`${layer.id}\` implies an ancestor \`${ancestor}\` that is not declared anywhere`, + ), + ), + ); +}; + +/** + * Rules are only worth stating if they are enforced, so a violation is an error + * rather than a note on a page. The message names representative files so the + * fix does not start with a search. + */ +export const checkRules = ( + model: ArchitectureModel, + rules: LayerRule[], +): Diagnostic[] => + rules.flatMap((rule) => + model.edges + .filter( + (edge) => + withinScope(edge.from, rule.from) && withinScope(edge.to, rule.to), + ) + .map((edge) => { + const example = edge.examples[0]; + const imports = + edge.fileDependencies === 1 + ? "1 import does" + : `${edge.fileDependencies} imports do`; + + return error( + example?.from ?? edge.from, + `\`${edge.from}\` must not depend on \`${edge.to}\` (${rule.reason}); ${imports}, e.g. ${example ? `${example.from} → ${example.to}` : "unknown"}`, + ); + }), + ); + +/** + * Layers with no files usually mean a declaration whose folder was emptied or + * renamed. Grouping layers legitimately hold no files of their own, so this is a + * warning: it is reported, but does not fail the build. + */ +export const checkEmptyLayers = (model: ArchitectureModel): Diagnostic[] => { + const hasChildren = new Set( + model.layers.flatMap((layer) => + layer.parent === null ? [] : [layer.parent], + ), + ); + + return model.layers + .filter((layer) => layer.fileCount === 0 && !hasChildren.has(layer.id)) + .map((layer) => + warning( + layer.declaredIn, + `layer \`${layer.id}\` has no source files and no sub-layers; was its folder moved or emptied?`, + ), + ); +}; + +/** + * A rule naming a layer that does not exist matches no edge and fires never. + * + * `checkRules` matches by layer id or ancestor prefix, so `reactt` is not a + * pattern that happens to match nothing: it is a rule that has been switched off + * by a typo, while still reading as enforced on the overview page. The rules are + * the only claims here checked against the import graph, so a silently inert one + * is the most expensive kind of mistake this file can allow. + */ +export const checkRuleTargets = ( + model: ArchitectureModel, + rules: LayerRule[], +): Diagnostic[] => { + const declared = new Set(model.layers.map((layer) => layer.id)); + + return rules.flatMap((rule) => + (["from", "to"] as const) + .filter((side) => !declared.has(rule[side])) + .map((side) => + error( + "architecture.config.ts", + `rule \`${rule.from}\` must not depend on \`${rule.to}\` names \`${rule[side]}\` as its \`${side}\`, which is not a declared layer, so the rule can never fire`, + ), + ), + ); +}; + +export const runChecks = (options: { + model: ArchitectureModel; + rules: LayerRule[]; +}): Diagnostic[] => [ + ...checkAncestorsDeclared(options.model), + ...checkRuleTargets(options.model, options.rules), + ...checkRules(options.model, options.rules), + ...checkEmptyLayers(options.model), +]; diff --git a/libs/@local/petrinaut-arch-docs/src/cli.ts b/libs/@local/petrinaut-arch-docs/src/cli.ts new file mode 100644 index 00000000000..f3c38fc8dcb --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/cli.ts @@ -0,0 +1,154 @@ +/** + * `arch-docs build` writes the bundle; `arch-docs check` reports on it. + * + * `check` reports without writing: it builds everything, discards the files, and + * fails on any annotation error — an unannotated file, an undeclared ancestor, a + * violated rule — so the map cannot quietly stop matching the code. + */ + +import { chmod, mkdir, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { config } from "../architecture.config"; +import { buildBundle, bundleTextFiles, type BuiltBundle } from "./build"; +import { countErrors, type Diagnostic } from "./diagnostics"; +import { canRenderDiagrams, renderD2 } from "./emit/d2"; + +const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url)); +const bundleRoot = join(repoRoot, config.outputDirectory); + +const supportsColour = process.stdout.isTTY === true; + +const colour = (code: string, text: string): string => + supportsColour ? `\u001B[${code}m${text}\u001B[0m` : text; + +const red = (text: string): string => colour("31", text); +const yellow = (text: string): string => colour("33", text); +const dim = (text: string): string => colour("2", text); + +const reportDiagnostics = (diagnostics: Diagnostic[]): { errors: number } => { + const errors = diagnostics.filter( + (diagnostic) => diagnostic.severity === "error", + ); + const warnings = diagnostics.filter( + (diagnostic) => diagnostic.severity === "warning", + ); + + for (const diagnostic of [...errors, ...warnings]) { + const location = + diagnostic.line === null + ? diagnostic.file + : `${diagnostic.file}:${diagnostic.line}`; + const label = + diagnostic.severity === "error" ? red("error") : yellow("warning"); + process.stderr.write(`${label} ${location}\n ${diagnostic.message}\n`); + } + + return { errors: countErrors(diagnostics) }; +}; + +const summarise = (bundle: BuiltBundle): void => { + const files = bundle.model.layers.reduce( + (total, layer) => total + layer.fileCount, + 0, + ); + + process.stdout.write( + dim( + `${bundle.model.layers.length} layers \u00b7 ${bundle.model.edges.length} edges \u00b7 ${files} files \u00b7 ${bundle.generated.length} generated pages \u00b7 ${bundle.authored.length} authored pages\n`, + ), + ); +}; + +/** Returns false when a diagram the pages already reference failed to render. */ +const writeBundle = async (bundle: BuiltBundle): Promise => { + // Generated pages, diagrams and components are rewritten wholesale so a + // renamed layer or a deleted component cannot leave an orphan behind, which + // would silently keep serving a description of code that no longer exists. + await rm(join(bundleRoot, "pages"), { recursive: true, force: true }); + await rm(join(bundleRoot, "diagrams"), { recursive: true, force: true }); + await rm(join(bundleRoot, "components"), { recursive: true, force: true }); + await mkdir(bundleRoot, { recursive: true }); + + for (const [path, contents] of bundleTextFiles(bundle)) { + const target = join(bundleRoot, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, contents, "utf8"); + } + + for (const name of bundle.diagramSources.keys()) { + const source = join(bundleRoot, `diagrams/${name}.d2`); + const output = join(bundleRoot, `diagrams/${name}.svg`); + const result = renderD2(repoRoot, source, output); + + if (!result.ok) { + // The pages were emitted expecting this SVG, because `d2` answered the + // availability probe. A bundle that references an image it does not + // contain fails the consuming site's build, so this stops here rather + // than writing one. + process.stderr.write( + `${red("error")} could not render ${name}.svg, and the pages already reference it\n ${result.error}\n`, + ); + return false; + } + + // d2 writes 0600, which leaves the committed SVG unreadable to anything + // serving the bundle under a different user. + await chmod(output, 0o644); + } + + return true; +}; + +const main = async (): Promise => { + const command = process.argv[2] ?? "build"; + + if (!["build", "check"].includes(command)) { + process.stderr.write( + `unknown command \`${command}\`; expected build or check\n`, + ); + return 2; + } + + // Probed before building so pages only reference diagrams that will exist. + // `check` never writes, so it does not care either way. + const diagramsAvailable = command === "check" || canRenderDiagrams(repoRoot); + + if (command === "build" && !diagramsAvailable) { + process.stderr.write( + `${yellow("warning")} d2 is unavailable, so the bundle is being written without rendered diagrams.\n Install it with \`mise install\` to include them.\n`, + ); + } + + const bundle = await buildBundle({ + repoRoot, + includeDiagrams: diagramsAvailable, + }); + const { errors } = reportDiagnostics(bundle.diagnostics); + summarise(bundle); + + if (command === "check") { + // `check` builds the whole bundle and discards the files. The build is + // what surfaces the diagnostics — an unannotated file, an undeclared + // ancestor, a violated rule — and because the bundle is never stored, there is no + // committed copy that could be out of date with the source. + return errors > 0 ? 1 : 0; + } + + if (errors > 0) { + process.stderr.write( + `\n${red("refusing to write")} the bundle while ${errors} annotation error${errors === 1 ? "" : "s"} remain\n`, + ); + return 1; + } + + if (!(await writeBundle(bundle))) { + return 1; + } + + process.stdout.write(`Wrote ${config.outputDirectory}\n`); + return 0; +}; + +process.exitCode = await main(); diff --git a/libs/@local/petrinaut-arch-docs/src/content.ts b/libs/@local/petrinaut-arch-docs/src/content.ts new file mode 100644 index 00000000000..7d26104c59d --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/content.ts @@ -0,0 +1,228 @@ +/** + * Hand-written MDX that ships in the same bundle as the generated pages. + * + * Authored pages carry the reasoning an import graph cannot supply. They end up + * in the same manifest as the generated pages, so a host renders one coherent + * set of docs. Slugs mirror the directory layout under `content/`. + */ + +import { readdir, readFile } from "node:fs/promises"; +import { join, relative } from "node:path"; + +import { parseFrontmatterRecord } from "./frontmatter"; +import { toPosix } from "./paths"; + +import type { Dirent } from "node:fs"; + +export interface AuthoredPage { + /** Path within the bundle. */ + path: string; + slug: string; + title: string; + description: string; + contents: string; + order: number; + /** + * Layer id this page explains, from the `attachTo` frontmatter key. + * + * When set, the page is placed beneath that layer's page rather than at the + * top level, so a hand-written guide sits with the generated reference for the + * same code instead of in a separate section. Null means a standalone page. + * + * Note this is *not* a layer declaration — it attaches to a layer declared in + * the source. Declaring layers from `content/` stays forbidden. + */ + attachTo: string | null; + /** Repo-relative source file, for error messages. */ + sourceFile: string; +} + +/** A diagram component shipped in the bundle for authored pages to import. */ +export interface AuthoredComponent { + /** Path within the bundle, e.g. `components/lanes.tsx`. */ + path: string; + /** Import name authors use, e.g. `lanes` for `@diagrams/lanes`. */ + name: string; + contents: string; +} + +export interface AuthoredContentResult { + pages: AuthoredPage[]; + components: AuthoredComponent[]; + errors: { file: string; message: string }[]; +} + +const authoredExtensions = new Set([".md", ".mdx"]); +const componentExtensions = new Set([".tsx", ".ts", ".css"]); + +/** Directory under `content/` holding importable diagram components. */ +const componentDirectory = "components"; + +const extensionOf = (name: string): string => { + const dot = name.lastIndexOf("."); + return dot === -1 ? "" : name.slice(dot).toLowerCase(); +}; + +/** + * A scalar frontmatter value as a trimmed string. + * + * YAML gives back the type it inferred, so `sidebar_order: 10` is a number and + * `title: 2026` is too. Anything that is not a scalar (a list, a nested + * mapping) has no sensible single-line reading and is treated as absent. + */ +const readString = (value: unknown): string | null => { + if (typeof value === "string") { + return value.trim() === "" ? null : value.trim(); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return null; +}; + +/** + * Reads `title`, `description`, `sidebar_order` and `attachTo` out of a page's + * frontmatter, without requiring the architecture-declaration shape. + */ +const readPageMeta = ( + record: Record | null, +): { + title: string | null; + description: string; + order: number; + attachTo: string | null; +} => { + if (record === null) { + return { title: null, description: "", order: 100, attachTo: null }; + } + + const rawOrder = record.sidebar_order; + const order = + typeof rawOrder === "number" ? rawOrder : Number(readString(rawOrder)); + + return { + title: readString(record.title), + description: readString(record.description) ?? "", + order: Number.isFinite(order) ? order : 100, + attachTo: readString(record.attachTo), + }; +}; + +/** Derives a heading-based title when frontmatter omits one. */ +const firstHeading = (markdown: string): string | null => { + const withoutFrontmatter = markdown.replace( + /^---\r?\n[\s\S]*?\r?\n---\r?\n?/u, + "", + ); + const match = /^#\s+(.+)$/mu.exec(withoutFrontmatter); + return match ? (match[1] ?? "").trim() : null; +}; + +const titleFromSlug = (slug: string): string => { + const last = slug.split("/").pop() ?? slug; + return last + .split("-") + .map((word) => + word === "" ? word : word[0]?.toUpperCase() + word.slice(1), + ) + .join(" "); +}; + +export const collectAuthoredContent = async (options: { + repoRoot: string; + contentDirectory: string; +}): Promise => { + const root = join(options.repoRoot, options.contentDirectory); + const pages: AuthoredPage[] = []; + const components: AuthoredComponent[] = []; + const errors: { file: string; message: string }[] = []; + + let entries: Dirent[]; + try { + entries = await readdir(root, { withFileTypes: true, recursive: true }); + } catch (cause) { + // An absent directory is a normal state, since `content/` is optional. + // Anything else (a permission error, a bad path) would otherwise drop every + // authored page and still report success. + if ((cause as NodeJS.ErrnoException).code !== "ENOENT") { + throw cause; + } + return { pages, components, errors }; + } + + for (const entry of entries) { + if (!entry.isFile()) { + continue; + } + + const componentPath = toPosix( + relative(root, join(entry.parentPath, entry.name)), + ); + + if (componentPath.startsWith(`${componentDirectory}/`)) { + if (componentExtensions.has(extensionOf(entry.name))) { + components.push({ + path: componentPath, + name: componentPath + .slice(componentDirectory.length + 1) + .replace(/\.[^.]+$/u, ""), + contents: await readFile(join(entry.parentPath, entry.name), "utf8"), + }); + } + continue; + } + + if (!authoredExtensions.has(extensionOf(entry.name))) { + continue; + } + + const absolutePath = join(entry.parentPath, entry.name); + const relativePath = toPosix(relative(root, absolutePath)); + const contents = await readFile(absolutePath, "utf8"); + + const { record, errors: frontmatterErrors } = + parseFrontmatterRecord(contents); + + // Checked as a key, not as a successful declaration parse. Going through + // the strict declaration schema meant an unrelated key such as `title` + // failed the parse and let the `layer` key through unreported. + if (record !== null && "layer" in record) { + errors.push({ + file: toPosix(relative(options.repoRoot, absolutePath)), + message: + "authored pages must not declare a layer; layer declarations belong in the source packages", + }); + } + + // `parseFrontmatterRecord` only reports unreadable YAML, which is the one + // failure an authored page cannot legitimately have. + for (const message of frontmatterErrors) { + errors.push({ + file: toPosix(relative(options.repoRoot, absolutePath)), + message, + }); + } + + const slug = relativePath.replace(/\.(?:md|mdx)$/iu, ""); + const meta = readPageMeta(record); + + pages.push({ + path: `pages/${slug}.mdx`, + slug, + title: meta.title ?? firstHeading(contents) ?? titleFromSlug(slug), + description: meta.description, + contents, + order: meta.order, + attachTo: meta.attachTo, + sourceFile: toPosix(relative(options.repoRoot, absolutePath)), + }); + } + + return { + pages: pages.sort((left, right) => left.slug.localeCompare(right.slug)), + components: components.sort((left, right) => + left.path.localeCompare(right.path), + ), + errors, + }; +}; diff --git a/libs/@local/petrinaut-arch-docs/src/diagnostics.ts b/libs/@local/petrinaut-arch-docs/src/diagnostics.ts new file mode 100644 index 00000000000..e33e8fe9663 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/diagnostics.ts @@ -0,0 +1,33 @@ +/** + * What every stage reports, and how. + * + * One shape for extraction, graph building and the checks, so the CLI formats + * them identically and no stage has to invent its own reporting. This lived in + * `extract.ts`, which meant the graph builder could only return bare strings and + * the caller had to guess a file to attribute them to. + */ + +export interface Diagnostic { + /** Repo-relative path the reader should open. */ + file: string; + line: number | null; + message: string; + severity: "error" | "warning"; +} + +/** Fails the build. */ +export const error = ( + file: string, + message: string, + line: number | null = null, +): Diagnostic => ({ file, line, message, severity: "error" }); + +/** Reported, but does not fail the build. */ +export const warning = ( + file: string, + message: string, + line: number | null = null, +): Diagnostic => ({ file, line, message, severity: "warning" }); + +export const countErrors = (diagnostics: Diagnostic[]): number => + diagnostics.filter((diagnostic) => diagnostic.severity === "error").length; diff --git a/libs/@local/petrinaut-arch-docs/src/emit/bundle-outputs.ts b/libs/@local/petrinaut-arch-docs/src/emit/bundle-outputs.ts new file mode 100644 index 00000000000..1d0b10dcd43 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/emit/bundle-outputs.ts @@ -0,0 +1,139 @@ +/** + * The bundle's index files: `manifest.json` and `architecture.md`. + * + * `manifest.json` is what a host site reads to build navigation without having + * to crawl the pages directory. `architecture.md` is the whole architecture as a + * single file, which is cheaper for a model to read than fetching thirty pages. + */ + +import { ARCHITECTURE_MODEL_VERSION, type ArchitectureModel } from "../model"; + +import type { AuthoredPage } from "../content"; +import type { GeneratedPage } from "./mdx"; + +export const BUNDLE_MANIFEST_VERSION = 1; + +export interface ManifestPage { + /** Path within the bundle. */ + path: string; + slug: string; + title: string; + description: string; + /** `generated` pages are rewritten on every build; `authored` are hand-written. */ + kind: "generated" | "authored"; + order: number; +} + +export interface BundleManifest { + manifestVersion: number; + modelVersion: number; + generator: string; + /** Relative path to the machine-readable model. */ + model: string; + pages: ManifestPage[]; +} + +export const buildManifest = (options: { + generator: string; + generated: GeneratedPage[]; + authored: AuthoredPage[]; +}): BundleManifest => { + const pages: ManifestPage[] = [ + ...options.generated.map((page) => ({ + path: page.path, + slug: page.slug, + title: page.title, + description: page.description, + kind: "generated" as const, + order: page.order, + })), + ...options.authored.map((page) => ({ + path: page.path, + slug: page.slug, + title: page.title, + description: page.description, + kind: "authored" as const, + order: page.order, + })), + ].sort( + (left, right) => + left.order - right.order || left.slug.localeCompare(right.slug), + ); + + return { + manifestVersion: BUNDLE_MANIFEST_VERSION, + modelVersion: ARCHITECTURE_MODEL_VERSION, + generator: options.generator, + model: "architecture.json", + pages, + }; +}; + +/** + * The whole architecture as one Markdown file. + * + * Ordered so a reader (human or model) meets the system top-down: what the + * packages are, what the rules are, then each layer with its role and + * dependencies. Deliberately terse — this is a reference, and + * every token spent on prose here is one an agent pays on every read. + */ +export const buildSingleFileArchitecture = ( + model: ArchitectureModel, +): string => { + const lines: string[] = [ + "# Petrinaut architecture", + "", + "Generated from annotations in the source. Do not edit — change the `@layerRoot`/`@role` annotations or the declaring README frontmatter instead.", + "", + "## Packages", + "", + ]; + + for (const pkg of model.packages) { + lines.push(`- \`${pkg.name}\` (\`${pkg.path}\`) — ${pkg.description}`); + } + + if (model.rules.length > 0) { + lines.push("", "## Enforced rules", ""); + for (const rule of model.rules) { + lines.push( + `- \`${rule.from}\` must not depend on \`${rule.to}\` — ${rule.reason}`, + ); + } + } + + lines.push("", "## Layers", ""); + + for (const layer of model.layers) { + lines.push( + `### ${layer.name} (\`${layer.id}\`)`, + "", + layer.role, + "", + `- Package: \`${layer.package}\``, + `- Declared in: \`${layer.declaredIn}\``, + `- Size: ${layer.fileCount} files, ${layer.lineCount} lines`, + ); + + const outgoing = model.edges.filter((edge) => edge.from === layer.id); + + if (outgoing.length > 0) { + const rendered = outgoing + .slice() + .sort((left, right) => right.fileDependencies - left.fileDependencies) + .map((edge) => `\`${edge.to}\` (${edge.fileDependencies})`) + .join(", "); + lines.push(`- Depends on: ${rendered}`); + } + + if (layer.references.length > 0) { + lines.push( + `- Further reading: ${layer.references.map((reference) => `\`${reference}\``).join(", ")}`, + ); + } + + lines.push(""); + } + + return `${lines.join("\n")}\n`; +}; diff --git a/libs/@local/petrinaut-arch-docs/src/emit/d2.test.ts b/libs/@local/petrinaut-arch-docs/src/emit/d2.test.ts new file mode 100644 index 00000000000..87ebbefe3c5 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/emit/d2.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; + +import { buildNeighbourhoodDiagram } from "./d2"; + +import type { Edge, Layer } from "../model"; + +/** + * The neighbourhood diagram is the one a reader opens first, and the only diagram + * that decides what to leave out. These tests pin what it draws and, more + * importantly, what it says about the parts it does not draw: a cap that silently + * dropped neighbours would make a layer look less connected than it is. + */ + +const layer = (id: string, name = id): Layer => ({ + id, + name, + parent: id.includes(".") ? id.slice(0, id.lastIndexOf(".")) : null, + package: "@test/pkg", + role: `role of ${id}`, + declaredIn: `src/${id}/index.ts`, + prose: null, + references: [], + files: [`src/${id}/index.ts`], + fileCount: 1, + lineCount: 1, +}); + +const edge = (from: string, to: string, count = 1): Edge => ({ + from, + to, + fileDependencies: count, + examples: [], + crossesPackage: false, +}); + +const build = (focus: string, layers: Layer[], edges: Edge[]): string => + buildNeighbourhoodDiagram(focus, layers, edges, "test"); + +describe("buildNeighbourhoodDiagram", () => { + const layers = [layer("core"), layer("core.a"), layer("core.b")]; + + it("draws dependencies and dependents in the right directions", () => { + const diagram = build("core.a", layers, [ + edge("core.a", "core.b", 3), + edge("core", "core.a", 2), + ]); + + expect(diagram).toContain("core_a -> core_b"); + expect(diagram).toContain("core -> core_a"); + expect(diagram).toContain("3 file-level dependencies"); + expect(diagram).toContain("2 file-level dependencies"); + }); + + it("marks the focus and labels nodes with their role", () => { + const diagram = build("core.a", layers, [edge("core.a", "core.b")]); + + expect(diagram).toContain("core_a: {class: [core; focus]"); + expect(diagram).toContain("role of core.b"); + }); + + it("flattens dotted ids so D2 does not synthesise container boxes", () => { + const diagram = build("core.a", layers, [edge("core.a", "core.b")]); + + expect(diagram).toContain("core_a"); + expect(diagram).not.toContain("core.a:"); + }); + + it("keeps both directions between a reciprocal pair", () => { + const diagram = build("core.a", layers, [ + edge("core.a", "core.b", 5), + edge("core.b", "core.a", 7), + ]); + + expect(diagram).toContain("core_a -> core_b"); + expect(diagram).toContain("core_b -> core_a"); + expect(diagram).toContain("5 file-level dependencies"); + expect(diagram).toContain("7 file-level dependencies"); + }); + + it("says so when a layer has no dependencies either way", () => { + const diagram = build("core.a", layers, [edge("core", "core.b")]); + + expect(diagram).toContain("no dependencies either way"); + }); + + describe("when a layer has more neighbours than fit", () => { + const many = [ + layer("hub"), + ...Array.from({ length: 15 }, (_, index) => layer(`n${index}`)), + ]; + // Descending weights, so the cap keeps the heaviest twelve. + const heavy = Array.from({ length: 15 }, (_, index) => + edge(`n${index}`, "hub", 15 - index), + ); + + it("draws twelve and collapses the rest into one node", () => { + const diagram = build("hub", many, heavy); + + const drawn = [...diagram.matchAll(/^n\d+: \{/gmu)]; + expect(drawn).toHaveLength(12); + expect(diagram).toContain("+3 further layers"); + }); + + it("reports the elided dependencies rather than dropping them", () => { + const diagram = build("hub", many, heavy); + + // The three lightest edges carry 3, 2 and 1 dependencies. + expect(diagram).toContain("6 file-level dependencies"); + expect(diagram).toContain("elided -> hub"); + }); + + it("keeps the heaviest neighbours and discards the lightest", () => { + const diagram = build("hub", many, heavy); + + expect(diagram).toContain("n0: {"); + expect(diagram).not.toMatch(/^n14: \{/mu); + }); + + it("points the elided edge outward when the focus is the importer", () => { + const outward = Array.from({ length: 15 }, (_, index) => + edge("hub", `n${index}`, 15 - index), + ); + const diagram = build("hub", many, outward); + + expect(diagram).toContain("hub -> elided"); + expect(diagram).not.toContain("elided -> hub"); + }); + }); +}); diff --git a/libs/@local/petrinaut-arch-docs/src/emit/d2.ts b/libs/@local/petrinaut-arch-docs/src/emit/d2.ts new file mode 100644 index 00000000000..d00e962616e --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/emit/d2.ts @@ -0,0 +1,421 @@ +/** + * Diagram generation. + * + * Dotted layer ids map straight onto D2's container syntax, so + * `core.simulation.monte-carlo` nests inside `core.simulation` inside `core` + * with no extra bookkeeping — the taxonomy the annotations declare *is* the + * diagram's grouping. + * + * Three kinds of diagram, each bounding its node count a different way: + * + * - the overview is a level cut at depth 1 — the top-level layers only; + * - a layer with sub-layers gets a drill-down of its direct children; + * - every layer, leaf or not, gets a neighbourhood showing what it depends on + * and what depends on it. + * + * The neighbourhood is the one a reader usually wants: landing on a layer page, + * the question is what this layer touches, not what sits inside it. Leaves have + * nothing inside them and are exactly where readers land. + * + * Both `.d2` source and rendered `.svg` are written. + */ + +import { spawnSync } from "node:child_process"; + +import type { Edge, Layer } from "../model"; + +const quote = (text: string): string => JSON.stringify(text); + +/** Top-level ancestor of a dotted id, used for colour classing. */ +const rootSegment = (id: string): string => id.split(".")[0] ?? id; + +const styleClasses = [ + `classes: {`, + ` core: {style.fill: "#dcecff"; style.stroke: "#3676b8"}`, + ` react: {style.fill: "#e8e0ff"; style.stroke: "#7051b5"}`, + ` ui: {style.fill: "#e2f4e8"; style.stroke: "#3d8055"}`, + ` petrinaut: {style.fill: "#fdeedc"; style.stroke: "#b5762f"}`, + ` other: {style.fill: "#f2f2f2"; style.stroke: "#777777"}`, + ` boundary: {style.stroke-dash: 4}`, + ` focus: {style.stroke-width: 3; style.bold: true}`, + ` elided: {style.fill: "#ffffff"; style.stroke: "#aaaaaa"; style.stroke-dash: 3; style.italic: true}`, + `}`, +].join("\n"); + +const knownRoots = ["core", "react", "ui", "petrinaut"]; + +const classFor = (id: string): string => { + const root = rootSegment(id); + return knownRoots.includes(root) ? root : "other"; +}; + +const pluralise = (count: number, singular: string, plural?: string): string => + `${count} ${count === 1 ? singular : (plural ?? `${singular}s`)}`; + +const header = (generatedBy: string): string => + `# Generated by ${generatedBy}. Edit the annotations in the source, not this file.\n\ndirection: right\n`; + +/** + * D2 reads `.` as container nesting, so a dotted id used as a key would make it + * synthesise ancestor boxes. Flattening to `_` keeps every node a peer, which is + * what a neighbourhood diagram wants — the focus and its neighbours sit + * alongside each other regardless of where they live in the taxonomy. + */ +const flatKey = (id: string): string => id.replace(/\./gu, "_"); + +/** + * How many neighbours a neighbourhood diagram draws before eliding the rest. + * + * Bounded because a widely-depended-on layer (`core.types`) would otherwise + * produce a diagram with every consumer in the system on it, which is accurate + * and unreadable. The elided count is drawn as its own node rather than dropped, + * so the diagram never implies a layer has fewer neighbours than it does. + */ +const maxNeighbours = 12; + +/** + * One diagram per layer, showing what it depends on and what depends on it. + * + * Only edges *incident to the focus* are drawn. Edges among the neighbours + * themselves are real but belong to those layers' own pages; including them + * turns a readable star into the same tangle the overview already avoids. + */ +export const buildNeighbourhoodDiagram = ( + focusId: string, + layers: Layer[], + edges: Edge[], + generatedBy: string, +): string => { + const layersById = new Map(layers.map((layer) => [layer.id, layer])); + const focus = layersById.get(focusId); + + /** Neighbour id → dependencies in each direction. */ + const neighbours = new Map(); + + const record = ( + otherId: string, + direction: "out" | "in", + count: number, + ): void => { + const entry = neighbours.get(otherId) ?? { out: 0, in: 0 }; + entry[direction] += count; + neighbours.set(otherId, entry); + }; + + for (const edge of edges) { + if (edge.from === focusId && edge.to !== focusId) { + record(edge.to, "out", edge.fileDependencies); + } else if (edge.to === focusId && edge.from !== focusId) { + record(edge.from, "in", edge.fileDependencies); + } + } + + const ranked = [...neighbours.entries()].sort( + ([leftId, left], [rightId, right]) => + right.out + right.in - (left.out + left.in) || + leftId.localeCompare(rightId), + ); + + const shown = ranked.slice(0, maxNeighbours); + const elided = ranked.slice(maxNeighbours); + + const nodeLines = [ + `${flatKey(focusId)}: {class: [${classFor(focusId)}; focus]; label: ${quote( + focus?.name ?? focusId, + )}; tooltip: ${quote(focus?.role ?? focusId)}}`, + ...shown.map(([id]) => { + const layer = layersById.get(id); + return `${flatKey(id)}: {class: ${classFor(id)}; label: ${quote( + layer?.name ?? id, + )}; tooltip: ${quote(layer?.role ?? id)}}`; + }), + ]; + + const elidedOut = elided.reduce((sum, [, counts]) => sum + counts.out, 0); + const elidedIn = elided.reduce((sum, [, counts]) => sum + counts.in, 0); + + if (elided.length > 0) { + const total = elidedOut + elidedIn; + nodeLines.push( + `elided: {class: elided; label: ${quote( + `+${pluralise(elided.length, "further layer")}`, + )}; tooltip: ${quote( + `${pluralise(total, "file-level dependency", "file-level dependencies")}, omitted to keep the diagram readable — the full list is in the tables below`, + )}}`, + ); + } + + const edgeLines: string[] = []; + + for (const [id, counts] of shown) { + if (counts.out > 0) { + edgeLines.push( + `${flatKey(focusId)} -> ${flatKey(id)}: {tooltip: ${quote( + pluralise( + counts.out, + "file-level dependency", + "file-level dependencies", + ), + )}}`, + ); + } + if (counts.in > 0) { + edgeLines.push( + `${flatKey(id)} -> ${flatKey(focusId)}: {tooltip: ${quote( + pluralise( + counts.in, + "file-level dependency", + "file-level dependencies", + ), + )}}`, + ); + } + } + + // Drawn dashed and in whichever directions the elided layers actually use, so + // the summary node reads as part of the graph rather than as a stray legend. + if (elidedOut > 0) { + edgeLines.push( + `${flatKey(focusId)} -> elided: {class: boundary; tooltip: ${quote( + pluralise( + elidedOut, + "file-level dependency", + "file-level dependencies", + ), + )}}`, + ); + } + if (elidedIn > 0) { + edgeLines.push( + `elided -> ${flatKey(focusId)}: {class: boundary; tooltip: ${quote( + pluralise(elidedIn, "file-level dependency", "file-level dependencies"), + )}}`, + ); + } + + if (nodeLines.length === 1) { + nodeLines.push( + `isolated: {class: elided; label: ${quote("no dependencies either way")}}`, + ); + } + + return [ + header(generatedBy), + `# what ${focusId} depends on, and what depends on it\n`, + nodeLines.join("\n"), + "\n# dependencies\n", + edgeLines.join("\n"), + "\n# styling\n", + styleClasses, + "", + ].join("\n"); +}; + +/** + * One diagram per layer that has sub-layers, showing only its *direct* children. + * + * Nesting the entire sub-tree produced an unreadable tangle — 23 layers under + * `core` with every inter-layer edge drawn is technically accurate and useless. + * Showing one level at a time, with edges aggregated up to that level, keeps each + * diagram legible and lets a reader drill down through the pages instead. + */ +export const buildSubtreeDiagram = ( + parentId: string, + layers: Layer[], + edges: Edge[], + generatedBy: string, +): string => { + const children = layers.filter((layer) => layer.parent === parentId); + const childIds = children.map((layer) => layer.id); + + /** + * D2 reads `.` as container nesting, so a full dotted id as a key would make + * it synthesise the ancestor boxes this diagram exists to leave out. The leaf + * segment is unique among siblings, which is all a single-level diagram needs. + */ + const key = (layerId: string): string => layerId.slice(parentId.length + 1); + + /** The direct child of `parentId` that `layerId` sits under, if any. */ + const childScopeOf = (layerId: string): string | null => + childIds.find( + (childId) => layerId === childId || layerId.startsWith(`${childId}.`), + ) ?? null; + + /** Total files in a child, including its own descendants. */ + const totalFiles = (childId: string): number => + layers + .filter( + (layer) => layer.id === childId || layer.id.startsWith(`${childId}.`), + ) + .reduce((total, layer) => total + layer.fileCount, 0); + + const nodeLines = children.map((child) => { + const descendants = layers.filter((layer) => + layer.id.startsWith(`${child.id}.`), + ).length; + const detail = + descendants === 0 + ? pluralise(totalFiles(child.id), "source file") + : `${pluralise(totalFiles(child.id), "source file")}, ${pluralise(descendants, "sub-layer")}`; + + return `${key(child.id)}: {class: ${classFor(child.id)}; label: ${quote(child.name)}; tooltip: ${quote(`${detail} · ${child.role}`)}}`; + }); + + const aggregated = new Map(); + for (const edge of edges) { + const from = childScopeOf(edge.from); + const to = childScopeOf(edge.to); + if (from === null || to === null || from === to) { + continue; + } + const pairKey = `${from}\u0000${to}`; + aggregated.set( + pairKey, + (aggregated.get(pairKey) ?? 0) + edge.fileDependencies, + ); + } + + const edgeLines = [...aggregated.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([edgeKey, count]) => { + const [from = "", to = ""] = edgeKey.split("\u0000"); + return `${key(from)} -> ${key(to)}: {tooltip: ${quote( + pluralise(count, "file-level dependency", "file-level dependencies"), + )}}`; + }); + + return [ + header(generatedBy), + `# direct sub-layers of ${parentId}\n`, + nodeLines.join("\n"), + "\n# dependencies\n", + edgeLines.join("\n"), + "\n# styling\n", + styleClasses, + "", + ].join("\n"); +}; + +/** The collapsed graph: top-level layers only, edges aggregated. */ +export const buildOverviewDiagram = ( + layers: Layer[], + edges: Edge[], + generatedBy: string, +): string => { + interface Group { + name: string; + fileCount: number; + roles: string[]; + } + + const groups = new Map(); + + for (const layer of layers) { + const root = rootSegment(layer.id); + const group = groups.get(root) ?? { name: root, fileCount: 0, roles: [] }; + group.fileCount += layer.fileCount; + if (layer.id === root) { + group.name = layer.name; + group.roles.unshift(layer.role); + } + groups.set(root, group); + } + + const aggregated = new Map(); + for (const edge of edges) { + const from = rootSegment(edge.from); + const to = rootSegment(edge.to); + if (from === to) { + continue; + } + const key = `${from} ${to}`; + aggregated.set(key, (aggregated.get(key) ?? 0) + edge.fileDependencies); + } + + const nodeLines = [...groups.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map( + ([id, group]) => + `${id}: {class: ${classFor(id)}; label: ${quote(group.name)}; tooltip: ${quote( + `${pluralise(group.fileCount, "source file")}${group.roles[0] ? ` · ${group.roles[0]}` : ""}`, + )}}`, + ); + + const edgeLines = [...aggregated.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, count]) => { + const [from = "", to = ""] = key.split(" "); + return `${from} -> ${to}: {tooltip: ${quote( + pluralise(count, "file-level dependency", "file-level dependencies"), + )}}`; + }); + + return [ + header(generatedBy), + "# top-level layers\n", + nodeLines.join("\n"), + "\n# dependencies\n", + edgeLines.join("\n"), + "\n# styling\n", + styleClasses, + "", + ].join("\n"); +}; + +/** + * Whether the `d2` renderer is available. + * + * Checked before pages are emitted so the bundle never references an SVG it + * did not produce. `d2` is a declared repo tool, but environments that install + * tools individually (Vercel) can legitimately lack it. + */ +export const canRenderDiagrams = (repoRoot: string): boolean => { + const result = spawnSync( + "mise", + ["exec", "--env", "dev", "--", "d2", "--version"], + { cwd: repoRoot, encoding: "utf8" }, + ); + return !result.error && result.status === 0; +}; +/** + * Renders a `.d2` file to SVG. + * + * `d2` is provided by mise, matching how the previous script invoked it. When it + * is unavailable the caller is told rather than the build failing outright: the + * `.d2` sources are the diffable artefact, and a missing renderer should not + * block regenerating the model and pages. + */ +export const renderD2 = ( + repoRoot: string, + sourcePath: string, + outputPath: string, +): { ok: true } | { ok: false; error: string } => { + const result = spawnSync( + "mise", + [ + "exec", + "--env", + "dev", + "--", + "d2", + "--layout", + "elk", + sourcePath, + outputPath, + ], + { cwd: repoRoot, encoding: "utf8" }, + ); + + if (result.error) { + return { ok: false, error: result.error.message }; + } + + if (result.status !== 0) { + return { + ok: false, + error: result.stderr || result.stdout || "d2 exited non-zero", + }; + } + + return { ok: true }; +}; diff --git a/libs/@local/petrinaut-arch-docs/src/emit/mdx.test.ts b/libs/@local/petrinaut-arch-docs/src/emit/mdx.test.ts new file mode 100644 index 00000000000..b88397cc082 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/emit/mdx.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; + +import { resolveAuthoredLinks, layerSlug } from "./mdx"; + +/** + * An authored page's final slug depends on its `attachTo`, so it cannot write a + * correct relative link by hand. These tests pin the resolution that replaces + * hand-written paths, including the failure case — an unresolved target must be + * reported, never emitted as-is and left to 404. + */ + +const layerSlugs = new Map([ + ["core", layerSlug("core")], + ["core.simulation", layerSlug("core.simulation")], + ["core.simulation.engine", layerSlug("core.simulation.engine")], +]); + +const docSlugs = new Map([ + ["index", "index"], + ["two-execution-paths", "two-execution-paths"], + ["simulation/memory-model", "architecture/core/simulation/memory-model"], +]); + +const resolve = (contents: string, fromSlug: string) => + resolveAuthoredLinks(contents, fromSlug, { layerSlugs, docSlugs }); + +describe("resolveAuthoredLinks", () => { + it("resolves a layer link from a top-level page", () => { + const { contents, unresolved } = resolve( + "See [the engine](layer:core.simulation.engine).", + "two-execution-paths", + ); + + expect(unresolved).toEqual([]); + expect(contents).toBe( + "See [the engine](architecture/core/simulation/engine).", + ); + }); + + it("resolves a layer link from a page attached deep in the tree", () => { + const { contents } = resolve( + "See [the engine](layer:core.simulation.engine).", + "architecture/core/simulation/worker/protocol", + ); + + // Same target, different depth — the path has to differ. + expect(contents).toBe("See [the engine](../engine)."); + }); + + it("resolves a doc link between attached pages", () => { + const { contents, unresolved } = resolve( + "See [memory](doc:simulation/memory-model).", + "architecture/core/simulation/worker/protocol", + ); + + expect(unresolved).toEqual([]); + expect(contents).toBe("See [memory](../memory-model)."); + }); + + it("resolves a doc link that climbs out of the architecture tree", () => { + const { contents } = resolve( + "See [paths](doc:two-execution-paths).", + "architecture/core/simulation/memory-model", + ); + + expect(contents).toBe("See [paths](../../../two-execution-paths)."); + }); + + it("preserves a fragment", () => { + const { contents } = resolve( + "See [engine](layer:core.simulation.engine#invariants).", + "two-execution-paths", + ); + + expect(contents).toBe( + "See [engine](architecture/core/simulation/engine#invariants).", + ); + }); + + it("reports an unknown layer instead of emitting a broken link", () => { + const { contents, unresolved } = resolve( + "See [nope](layer:core.nonexistent).", + "index", + ); + + expect(unresolved).toEqual(["layer:core.nonexistent"]); + // Left untouched so the diagnostic is the only signal, not a silent rewrite. + expect(contents).toBe("See [nope](layer:core.nonexistent)."); + }); + + it("reports an unknown doc", () => { + const { unresolved } = resolve("[gone](doc:missing-page).", "index"); + + expect(unresolved).toEqual(["doc:missing-page"]); + }); + + it("leaves ordinary links alone", () => { + const source = + "[external](https://example.com) [relative](../sibling) [anchor](#section)"; + const { contents, unresolved } = resolve(source, "index"); + + expect(contents).toBe(source); + expect(unresolved).toEqual([]); + }); + + it("does not rewrite a scheme-like string outside a link target", () => { + const source = "Run `yarn doc:architecture` to regenerate."; + const { contents, unresolved } = resolve(source, "index"); + + expect(contents).toBe(source); + expect(unresolved).toEqual([]); + }); + + it("resolves every link in a page, not just the first", () => { + const { contents, unresolved } = resolve( + "[a](layer:core) then [b](layer:core.simulation)", + "index", + ); + + expect(unresolved).toEqual([]); + expect(contents).toBe( + "[a](architecture/core) then [b](architecture/core/simulation)", + ); + }); +}); diff --git a/libs/@local/petrinaut-arch-docs/src/emit/mdx.ts b/libs/@local/petrinaut-arch-docs/src/emit/mdx.ts new file mode 100644 index 00000000000..a549da9a6ec --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/emit/mdx.ts @@ -0,0 +1,523 @@ +/** + * MDX page generation. + * + * Output here is deliberately **framework-neutral**: YAML frontmatter plus plain + * CommonMark. No JSX, no imports, no framework-specific components. That + * constraint is what lets the same bundle render in the Starlight site, in + * hash.dev's Next.js MDX pipeline, and as plain text for an AI agent — a single + * `` component here would break two of those three. + * + * Diagrams are referenced as relative image paths, and every layer page links + * back to the annotation that declared it so a reader can go straight from the + * rendered claim to the source of truth. + */ + +import { posix } from "node:path"; + +import type { ArchitectureModel, Edge, Layer } from "../model"; + +export interface GeneratedPage { + /** Path within the bundle, e.g. `pages/core.simulation.mdx`. */ + path: string; + /** Route-ish identifier a host can map onto its own URL space. */ + slug: string; + title: string; + description: string; + contents: string; + order: number; +} + +/** + * Sidebar order at which generated pages begin. + * + * Authored pages are the narrative entry to the docs and generated pages are + * reference, so the two sets are kept in separate bands rather than interleaved + * by number. An authored page can still sort itself after the reference section + * by choosing a `sidebar_order` above this. + */ +export const GENERATED_ORDER_BASE = 1000; + +/** + * Backslashes are escaped before pipes, not after. + * + * Escaping only the pipe turns a role containing `a\|b` into `a\\|b`, which + * Markdown reads as a literal backslash followed by an unescaped cell + * separator, splitting the row. Roles are prose written by hand, so this is + * reachable by anyone who writes one. + */ +const escapeTableCell = (text: string): string => + text.replace(/\\/gu, "\\\\").replace(/\|/gu, "\\|").replace(/\n/gu, " "); + +/** Serialises frontmatter by hand so the output stays byte-stable. */ +const frontmatter = (fields: Record): string => { + const lines = Object.entries(fields).map( + ([key, value]) => + `${key}: ${typeof value === "number" ? value : JSON.stringify(value)}`, + ); + return ["---", ...lines, "---", ""].join("\n"); +}; + +const sourceLink = (sourceUrlPrefix: string, file: string): string => + `${sourceUrlPrefix}${file}`; + +/** The slug a layer's generated page occupies. */ +export const layerSlug = (id: string): string => + `architecture/${id.replace(/\./gu, "/")}`; + +/** + * Rewrites relative links in embedded README prose to absolute source URLs. + * + * A README's `[engine](./engine/README.md)` is correct where the README lives + * and broken once the prose is embedded in a docs page served from somewhere + * else entirely. Resolving against the README's own directory keeps those links + * working wherever the bundle is mounted. + */ +export const rewriteRelativeLinks = ( + prose: string, + declaredIn: string, + sourceUrlPrefix: string, +): string => { + const baseDirectory = posix.dirname(declaredIn); + + return prose.replace( + /(!?\[[^\]]*\])\(([^)\s]+)(\s+"[^"]*")?\)/gu, + (match, label: string, target: string, title: string | undefined) => { + if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/iu.test(target)) { + return match; + } + + const [path = "", fragment] = target.split("#"); + + if (path === "") { + return match; + } + + const resolved = posix.normalize(posix.join(baseDirectory, path)); + + // A link that escapes the repository root cannot be made absolute. + if (resolved.startsWith("..")) { + return match; + } + + return `${label}(${sourceUrlPrefix}${resolved}${fragment === undefined ? "" : `#${fragment}`}${title ?? ""})`; + }, + ); +}; + +/** + * Relative link from one bundle page to another, so the bundle works when + * mounted at any base path. + * + * Resolved against the *slug* — page links are followed in URL space by a + * reader, and assume slugs map to URLs without a trailing slash. + */ +const relativeTo = (fromSlug: string, toSlug: string): string => { + const relative = posix.relative(posix.dirname(fromSlug), toSlug); + return relative === "" ? "." : relative; +}; + +/** + * Relative path from a page's *file* to an asset in the bundle. + * + * Deliberately different from `relativeTo`: MDX toolchains resolve image paths + * against the file on disk at build time, not against the page's URL. Using the + * slug-relative form here produced `diagrams/x.svg` from `pages/architecture.mdx`, + * which points at a `pages/diagrams/` directory that does not exist. + */ +const assetPathFrom = (slug: string, assetPath: string): string => + posix.relative(posix.dirname(`pages/${slug}`), assetPath); + +/** + * Resolves `doc:` and `layer:` link targets in authored pages. + * + * An authored page's final slug depends on its `attachTo`, so it cannot know + * its own depth and therefore cannot write a correct relative link by hand. + * These two schemes let a page name its target and have the path computed: + * + * - `[text](layer:core.simulation.engine)` → that layer's generated page + * - `[text](doc:two-execution-paths)` → another authored page, by its file slug + * + * Unresolvable targets are reported rather than silently emitted, because a + * broken link here is invisible until someone clicks it. + */ +export const resolveAuthoredLinks = ( + contents: string, + fromSlug: string, + options: { + layerSlugs: Map; + docSlugs: Map; + }, +): { contents: string; unresolved: string[] } => { + const unresolved: string[] = []; + + const resolved = contents.replace( + /(\]\()(layer|doc):([^)\s#]+)(#[^)\s]*)?(\))/gu, + ( + match, + open: string, + scheme: string, + target: string, + fragment: string | undefined, + close: string, + ) => { + const slug = + scheme === "layer" + ? options.layerSlugs.get(target) + : options.docSlugs.get(target); + + if (slug === undefined) { + unresolved.push(`${scheme}:${target}`); + return match; + } + + return `${open}${relativeTo(fromSlug, slug)}${fragment ?? ""}${close}`; + }, + ); + + return { contents: resolved, unresolved }; +}; + +/** + * Rewrites `@diagrams/x` import specifiers to a path relative to the page. + * + * Same problem as `layer:`/`doc:` links: a page's depth depends on its + * `attachTo`, so it cannot write a correct relative import by hand. Authors use + * a stable alias and the real path is computed at emit time. + */ +export const resolveComponentImports = ( + contents: string, + fromSlug: string, + available: Set, +): { contents: string; unresolved: string[] } => { + const unresolved: string[] = []; + const fromDirectory = posix.dirname(`pages/${fromSlug}`); + + const resolved = contents.replace( + /(["'])@diagrams\/([^"']+)\1/gu, + (match, quote: string, name: string) => { + if (!available.has(name)) { + unresolved.push(`@diagrams/${name}`); + return match; + } + + const target = posix.relative(fromDirectory, `components/${name}`); + return `${quote}${target.startsWith(".") ? target : `./${target}`}${quote}`; + }, + ); + + return { contents: resolved, unresolved }; +}; + +const describeEdges = ( + layer: Layer, + edges: Edge[], + layersById: Map, + slug: string, +): string[] => { + const outgoing = edges.filter((edge) => edge.from === layer.id); + const incoming = edges.filter((edge) => edge.to === layer.id); + + const sections: string[] = []; + + const table = ( + heading: string, + intro: string, + rows: Edge[], + direction: "to" | "from", + ): string[] => { + if (rows.length === 0) { + return []; + } + + return [ + `## ${heading}`, + "", + intro, + "", + "| Layer | Imports | Package boundary |", + "| --- | --- | --- |", + ...rows + .slice() + .sort((left, right) => right.fileDependencies - left.fileDependencies) + .map((edge) => { + const otherId = direction === "to" ? edge.to : edge.from; + const other = layersById.get(otherId); + const label = other ? other.name : otherId; + const link = `[${escapeTableCell(label)}](${relativeTo(slug, layerSlug(otherId))})`; + const crosses = edge.crossesPackage ? "crossed" : "—"; + return `| ${link} | ${edge.fileDependencies} | ${crosses} |`; + }), + "", + ]; + }; + + sections.push( + ...table( + "Depends on", + "Aggregated from real TypeScript imports.", + outgoing, + "to", + ), + ); + sections.push( + ...table( + "Depended on by", + "Who reaches into this layer.", + incoming, + "from", + ), + ); + + return sections; +}; + +const buildLayerPage = ( + layer: Layer, + model: ArchitectureModel, + layersById: Map, + sourceUrlPrefix: string, + order: number, + neighbourhoodDiagram: string | null, + subtreeDiagram: string | null, + attachedGuides: { slug: string; title: string; description: string }[], +): GeneratedPage => { + const slug = layerSlug(layer.id); + const children = model.layers.filter((other) => other.parent === layer.id); + + const body: string[] = []; + + body.push(`> ${layer.role}`, ""); + + body.push( + [ + `**Package** \`${layer.package}\``, + `**Layer id** \`${layer.id}\``, + `**Files** ${layer.fileCount}`, + `**Lines** ${layer.lineCount.toLocaleString("en-US")}`, + ].join(" · "), + "", + ); + + body.push( + `Declared in [\`${layer.declaredIn}\`](${sourceLink(sourceUrlPrefix, layer.declaredIn)}).`, + "", + ); + + if (neighbourhoodDiagram !== null) { + body.push( + `![What ${layer.name} depends on, and what depends on it](${assetPathFrom( + slug, + `diagrams/${neighbourhoodDiagram}.svg`, + )})`, + "", + ); + } + + if (subtreeDiagram !== null) { + body.push( + `![Layers within ${layer.name}](${assetPathFrom( + slug, + `diagrams/${subtreeDiagram}.svg`, + )})`, + "", + ); + } + + if (children.length > 0) { + body.push( + "## Sub-layers", + "", + ...children.map( + (child) => + `- [${child.name}](${relativeTo(slug, layerSlug(child.id))}) — ${child.role}`, + ), + "", + ); + } + + if (attachedGuides.length > 0) { + body.push( + "## Guides", + "", + "Hand-written explanations of this layer. Unlike the rest of this page, they are not generated and not checked against the code.", + "", + ...attachedGuides.map( + (guide) => + `- [${guide.title}](${relativeTo(slug, guide.slug)})${guide.description === "" ? "" : ` — ${guide.description}`}`, + ), + "", + ); + } + + body.push(...describeEdges(layer, model.edges, layersById, slug)); + + if (layer.prose !== null) { + body.push( + "## Notes", + "", + rewriteRelativeLinks(layer.prose, layer.declaredIn, sourceUrlPrefix), + "", + ); + } + + if (layer.references.length > 0) { + body.push( + "## Further reading", + "", + ...layer.references.map( + (reference) => + `- [\`${reference}\`](${sourceLink(sourceUrlPrefix, reference)})`, + ), + "", + ); + } + + if (layer.files.length > 0) { + // A per-file list would dominate the page (the editor layer alone resolves + // over a hundred files) and every consumer that actually wants the list can + // read `files` from architecture.json instead. + const folder = posix.dirname(layer.declaredIn); + body.push( + "## Source", + "", + `${layer.fileCount} file${layer.fileCount === 1 ? "" : "s"} resolve to this layer, rooted at [\`${folder}\`](${sourceUrlPrefix}${folder}) — files under a sub-layer's folder belong to that sub-layer instead. The full list is in \`architecture.json\`.`, + "", + ); + } + + return { + path: `pages/${slug}.mdx`, + slug, + title: layer.name, + description: layer.role, + order, + contents: + frontmatter({ + title: layer.name, + description: layer.role, + sidebar_order: order, + }) + `\n${body.join("\n")}`, + }; +}; + +const buildOverviewPage = ( + model: ArchitectureModel, + overviewDiagram: string | null, +): GeneratedPage => { + const slug = "architecture"; + const roots = model.layers.filter((layer) => layer.parent === null); + + const body: string[] = [ + "> Generated from annotations in the Petrinaut source. Every layer and edge on this page was read out of the code, not drawn by hand.", + "", + ]; + + if (overviewDiagram !== null) { + body.push( + `![Top-level layers and the dependencies between them](${assetPathFrom( + slug, + `diagrams/${overviewDiagram}.svg`, + )})`, + "", + ); + } + + body.push( + "## Top-level layers", + "", + "| Layer | Responsibility | Files |", + "| --- | --- | --- |", + ...roots.map( + (layer) => + `| [${escapeTableCell(layer.name)}](${relativeTo(slug, layerSlug(layer.id))}) | ${escapeTableCell(layer.role)} | ${model.layers + .filter( + (other) => + other.id === layer.id || other.id.startsWith(`${layer.id}.`), + ) + .reduce((total, other) => total + other.fileCount, 0)} |`, + ), + "", + ); + + body.push( + "## Packages", + "", + "| Package | Path | Description |", + "| --- | --- | --- |", + ...model.packages.map( + (pkg) => + `| \`${pkg.name}\` | \`${pkg.path}\` | ${escapeTableCell(pkg.description)} |`, + ), + "", + ); + + if (model.rules.length > 0) { + body.push( + "## Enforced rules", + "", + "These are checked against the real import graph whenever the bundle is built.", + "", + "| Rule | Reason |", + "| --- | --- |", + ...model.rules.map( + (rule) => + `| \`${rule.from}\` must not depend on \`${rule.to}\` | ${escapeTableCell(rule.reason)} |`, + ), + "", + ); + } + + return { + path: `pages/${slug}.mdx`, + slug, + title: "Architecture", + description: + "Generated map of the Petrinaut packages: layers and the dependencies between them.", + order: GENERATED_ORDER_BASE, + contents: + frontmatter({ + title: "Architecture", + description: + "Generated map of the Petrinaut packages: layers and the dependencies between them.", + sidebar_order: GENERATED_ORDER_BASE, + }) + `\n${body.join("\n")}`, + }; +}; + +export const buildPages = ( + model: ArchitectureModel, + options: { + sourceUrlPrefix: string; + overviewDiagram: string | null; + /** Layer id → diagram name showing its dependencies and dependents. */ + neighbourhoodDiagrams: Map; + /** Layer id → diagram name showing its direct children, for parents only. */ + subtreeDiagrams: Map; + /** Authored guides attached to each layer id. */ + guidesByLayer?: Map< + string, + { slug: string; title: string; description: string }[] + >; + }, +): GeneratedPage[] => { + const layersById = new Map(model.layers.map((layer) => [layer.id, layer])); + + const pages = [buildOverviewPage(model, options.overviewDiagram)]; + + model.layers.forEach((layer, index) => { + pages.push( + buildLayerPage( + layer, + model, + layersById, + options.sourceUrlPrefix, + GENERATED_ORDER_BASE + index + 1, + options.neighbourhoodDiagrams.get(layer.id) ?? null, + options.subtreeDiagrams.get(layer.id) ?? null, + options.guidesByLayer?.get(layer.id) ?? [], + ), + ); + }); + + return pages; +}; diff --git a/libs/@local/petrinaut-arch-docs/src/extract.test.ts b/libs/@local/petrinaut-arch-docs/src/extract.test.ts new file mode 100644 index 00000000000..15397028cfc --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/extract.test.ts @@ -0,0 +1,216 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { extract } from "./extract"; + +import type { ArchitecturePackage } from "./model"; + +/** + * The extractor walks a real directory tree, so these tests build small trees in + * a temp dir. Inheritance is the part most worth pinning down: it is what makes + * ~40 declarations cover 400 files, and a regression there would silently + * re-bucket files rather than fail loudly. + */ + +let root: string; + +const pkg: ArchitecturePackage = { + name: "@test/pkg", + path: "pkg", + description: "test package", + language: "typescript", + sourceDirectory: "src", +}; + +const write = async (relativePath: string, contents: string): Promise => { + const absolute = join(root, relativePath); + await mkdir(join(absolute, ".."), { recursive: true }); + await writeFile(absolute, contents, "utf8"); +}; + +const run = async () => + extract({ + repoRoot: root, + packages: [pkg], + ignoredDirectories: ["node_modules"], + ignoredFilePattern: /\.test\.ts$/u, + }); + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "arch-docs-extract-")); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe("extract", () => { + it("assigns files to the nearest declaring ancestor", async () => { + await write( + "pkg/src/README.md", + "---\nlayer: core\nrole: Root\n---\n\nRoot prose.\n", + ); + await write( + "pkg/src/engine/README.md", + "---\nlayer: core.engine\nrole: Engine\n---\n", + ); + await write("pkg/src/top.ts", "export const a = 1;\n"); + await write("pkg/src/engine/step.ts", "export const b = 2;\n"); + await write("pkg/src/engine/deep/nested.ts", "export const c = 3;\n"); + + const { layers, fileLayers, diagnostics } = await run(); + + expect(diagnostics).toEqual([]); + expect(fileLayers.get("pkg/src/top.ts")).toBe("core"); + expect(fileLayers.get("pkg/src/engine/step.ts")).toBe("core.engine"); + // Inherits through a folder that declares nothing of its own. + expect(fileLayers.get("pkg/src/engine/deep/nested.ts")).toBe("core.engine"); + + const engine = layers.find((layer) => layer.id === "core.engine"); + expect(engine?.fileCount).toBe(2); + expect(layers.find((layer) => layer.id === "core")?.fileCount).toBe(1); + }); + + it("captures README prose as the layer body", async () => { + await write( + "pkg/src/README.md", + "---\nlayer: core\nrole: Root\n---\n\n# Title\n\nBody text.\n", + ); + await write("pkg/src/a.ts", "export const a = 1;\n"); + + const { layers } = await run(); + + expect(layers[0]?.prose).toBe("# Title\n\nBody text."); + }); + + it("declares a layer from a @layerRoot entry file", async () => { + await write( + "pkg/src/index.ts", + `/**\n * @layerRoot core\n * @role Does the thing\n */\nexport const a = 1;\n`, + ); + + const { layers, diagnostics } = await run(); + + expect(diagnostics).toEqual([]); + expect(layers).toHaveLength(1); + expect(layers[0]?.name).toBe("core"); + expect(layers[0]?.role).toBe("Does the thing"); + expect(layers[0]?.declaredIn).toBe("pkg/src/index.ts"); + }); + + it("requires a role alongside @layerRoot", async () => { + await write( + "pkg/src/index.ts", + `/**\n * @layerRoot core\n */\nexport const a = 1;\n`, + ); + + const { diagnostics } = await run(); + + expect( + diagnostics.some((diagnostic) => diagnostic.message.includes("@role")), + ).toBe(true); + }); + + it("reports a file that no declaration covers", async () => { + await write("pkg/src/orphan.ts", "export const a = 1;\n"); + + const { diagnostics } = await run(); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toContain("no layer resolves"); + }); + + it("rejects two declarations on one folder", async () => { + await write("pkg/src/README.md", "---\nlayer: core\nrole: Root\n---\n"); + await write( + "pkg/src/index.ts", + `/**\n * @layerRoot core.other\n * @role Other\n */\nexport const a = 1;\n`, + ); + + const { diagnostics } = await run(); + + expect( + diagnostics.some((diagnostic) => + diagnostic.message.includes("may declare at most one layer"), + ), + ).toBe(true); + }); + + it("rejects the same layer id declared twice", async () => { + await write("pkg/src/README.md", "---\nlayer: core\nrole: Root\n---\n"); + await write("pkg/src/a/README.md", "---\nlayer: core\nrole: Dup\n---\n"); + + const { diagnostics } = await run(); + + expect( + diagnostics.some((diagnostic) => + diagnostic.message.includes("is already declared in"), + ), + ).toBe(true); + }); + + it("lists non-declaring markdown as references", async () => { + await write("pkg/src/README.md", "---\nlayer: core\nrole: Root\n---\n"); + await write("pkg/src/BUFFER_ABI.md", "# ABI\n\nDetails.\n"); + await write("pkg/src/a.ts", "export const a = 1;\n"); + + const { layers } = await run(); + + expect(layers[0]?.references).toEqual(["pkg/src/BUFFER_ABI.md"]); + }); + + it("excludes files matching the ignore pattern", async () => { + await write("pkg/src/README.md", "---\nlayer: core\nrole: Root\n---\n"); + await write("pkg/src/a.ts", "export const a = 1;\n"); + await write("pkg/src/a.test.ts", "export const b = 2;\n"); + + const { layers, fileLayers } = await run(); + + expect(layers[0]?.fileCount).toBe(1); + expect(fileLayers.has("pkg/src/a.test.ts")).toBe(false); + }); + + it("counts non-blank lines only", async () => { + await write("pkg/src/README.md", "---\nlayer: core\nrole: Root\n---\n"); + await write("pkg/src/a.ts", "const a = 1;\n\n\nconst b = 2;\n"); + + const { layers } = await run(); + + expect(layers[0]?.lineCount).toBe(2); + }); + + it("produces layers sorted by id, so output is stable", async () => { + await write("pkg/src/README.md", "---\nlayer: core\nrole: Root\n---\n"); + await write("pkg/src/z/README.md", "---\nlayer: core.z\nrole: Z\n---\n"); + await write("pkg/src/a/README.md", "---\nlayer: core.a\nrole: A\n---\n"); + await write("pkg/src/a/f.ts", "export const a = 1;\n"); + await write("pkg/src/z/f.ts", "export const a = 1;\n"); + + const { layers } = await run(); + + expect(layers.map((layer) => layer.id)).toEqual([ + "core", + "core.a", + "core.z", + ]); + }); + + it("derives the parent id from the dotted layer id", async () => { + await write("pkg/src/README.md", "---\nlayer: core\nrole: Root\n---\n"); + await write( + "pkg/src/a/b/README.md", + "---\nlayer: core.a.b\nrole: Deep\n---\n", + ); + await write("pkg/src/a/b/f.ts", "export const a = 1;\n"); + + const { layers } = await run(); + + expect(layers.find((layer) => layer.id === "core")?.parent).toBeNull(); + expect(layers.find((layer) => layer.id === "core.a.b")?.parent).toBe( + "core.a", + ); + }); +}); diff --git a/libs/@local/petrinaut-arch-docs/src/extract.ts b/libs/@local/petrinaut-arch-docs/src/extract.ts new file mode 100644 index 00000000000..dad5cea082e --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/extract.ts @@ -0,0 +1,388 @@ +/** + * Walks the configured packages and resolves every source file to a layer. + * + * This is the module that replaces the hand-maintained path→layer switch that + * used to live in `petrinaut-core/scripts/generate-dependency-diagrams.mjs`. + * There, the mapping was ~180 lines of `if (path.startsWith(...))` sitting far + * from the code it described, with a silent fallback that mis-bucketed anything + * moved or renamed. Here the mapping is declared next to the code and resolved + * by inheritance: declare a layer on a folder, and every descendant file + * belongs to it until a deeper declaration says otherwise. + */ + +import { readdir, readFile } from "node:fs/promises"; +import { join, posix, relative } from "node:path"; + +import { error, type Diagnostic } from "./diagnostics"; +import { parseFrontmatter } from "./frontmatter"; +import { layerSchema, parentLayerId } from "./model"; +import { toPosix } from "./paths"; +import { sourceExtensions, sourceRootOf } from "./scope"; +import { scanTags } from "./tags"; + +import type { Layer, ArchitecturePackage } from "./model"; + +/** A layer declaration plus everything resolved onto it during the walk. */ +interface LayerAccumulator { + id: string; + name: string; + package: string; + role: string; + declaredIn: string; + /** Folder the declaration governs; descendants inherit from it. */ + scope: string; + prose: string | null; + references: string[]; + files: string[]; + lineCount: number; +} + +export interface ExtractionResult { + layers: Layer[]; + /** Repo-relative source file → layer id, for the graph stage. */ + fileLayers: Map; + diagnostics: Diagnostic[]; +} + +export interface ExtractOptions { + repoRoot: string; + packages: ArchitecturePackage[]; + /** Directory names skipped entirely during the walk. */ + ignoredDirectories: string[]; + /** Regex matched against repo-relative paths to skip files. */ + ignoredFilePattern: RegExp; +} + +const sourceExtensionSet = new Set(sourceExtensions); + +const extensionOf = (name: string): string => { + const dot = name.lastIndexOf("."); + return dot === -1 ? "" : name.slice(dot); +}; + +const countNonBlankLines = (text: string): number => + text.split("\n").filter((line) => line.trim() !== "").length; + +interface WalkEntry { + /** Repo-relative, posix-separated. */ + path: string; + absolutePath: string; + /** Repo-relative posix directory containing the file. */ + directory: string; + name: string; +} + +const walkFiles = async ( + root: string, + repoRoot: string, + ignoredDirectories: Set, +): Promise => { + const entries: WalkEntry[] = []; + + const visit = async (absoluteDirectory: string): Promise => { + const contents = await readdir(absoluteDirectory, { withFileTypes: true }); + + for (const item of contents) { + const absolutePath = join(absoluteDirectory, item.name); + + if (item.isDirectory()) { + if (!ignoredDirectories.has(item.name)) { + await visit(absolutePath); + } + continue; + } + + if (!item.isFile()) { + continue; + } + + const relativePath = toPosix(relative(repoRoot, absolutePath)); + entries.push({ + path: relativePath, + absolutePath, + directory: posix.dirname(relativePath), + name: item.name, + }); + } + }; + + await visit(root); + return entries.sort((left, right) => left.path.localeCompare(right.path)); +}; + +/** + * Finds the declaration governing a path by walking up the directory chain and + * taking the first (deepest) match. Ties cannot happen: a folder may hold at + * most one declaration, which `collectDeclarations` enforces. + */ +const resolveScope = ( + directory: string, + scopes: Map, +): string | null => { + let current = directory; + + for (;;) { + const layerId = scopes.get(current); + if (layerId !== undefined) { + return layerId; + } + + const parent = posix.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +}; + +export const extract = async ( + options: ExtractOptions, +): Promise => { + const { repoRoot, packages, ignoredFilePattern } = options; + const ignoredDirectories = new Set(options.ignoredDirectories); + const diagnostics: Diagnostic[] = []; + + const accumulators = new Map(); + /** Folder → layer id it declares. */ + const scopes = new Map(); + /** Layer id → the file that declared it, for duplicate reporting. */ + const declaredBy = new Map(); + + const packageEntries = new Map(); + + for (const pkg of packages) { + // A package whose language has no extractor would otherwise contribute no + // files, no layers and no diagnostics — appearing in the model as covered + // while being entirely undescribed. That is the silent mis-bucketing this + // system exists to remove, so it is an error rather than a skip. + if (pkg.language !== "typescript") { + diagnostics.push( + error( + `${pkg.path}/package.json`, + `package \`${pkg.name}\` is configured as \`${pkg.language}\`, which has no extractor — it would be listed in the model with no layers. Remove it from architecture.config.ts until one exists.`, + ), + ); + continue; + } + + const entries = await walkFiles( + join(repoRoot, sourceRootOf(pkg)), + repoRoot, + ignoredDirectories, + ); + packageEntries.set( + pkg.name, + entries.filter((entry) => !ignoredFilePattern.test(entry.path)), + ); + } + + // Pass 1 — collect declarations, so inheritance can be resolved in pass 2 + // regardless of the order files are visited in. + for (const pkg of packages) { + for (const entry of packageEntries.get(pkg.name) ?? []) { + const isMarkdown = entry.name.toLowerCase().endsWith(".md"); + const isSource = sourceExtensionSet.has(extensionOf(entry.name)); + + if (!isMarkdown && !isSource) { + continue; + } + + const contents = await readFile(entry.absolutePath, "utf8"); + + const register = ( + accumulator: Omit< + LayerAccumulator, + "files" | "lineCount" | "references" + >, + ): void => { + const existingFile = declaredBy.get(accumulator.id); + if (existingFile !== undefined) { + diagnostics.push( + error( + accumulator.declaredIn, + `layer \`${accumulator.id}\` is already declared in ${existingFile}`, + ), + ); + return; + } + + const existingScope = scopes.get(accumulator.scope); + if (existingScope !== undefined) { + diagnostics.push( + error( + accumulator.declaredIn, + `${accumulator.scope} already declares layer \`${existingScope}\`; a folder may declare at most one layer`, + ), + ); + return; + } + + declaredBy.set(accumulator.id, accumulator.declaredIn); + scopes.set(accumulator.scope, accumulator.id); + accumulators.set(accumulator.id, { + ...accumulator, + references: [], + files: [], + lineCount: 0, + }); + }; + + if (isMarkdown) { + const { declaration, body, errors } = parseFrontmatter(contents); + + for (const message of errors) { + diagnostics.push(error(entry.path, message)); + } + + if (declaration) { + const segments = declaration.layer.split("."); + register({ + id: declaration.layer, + name: segments[segments.length - 1] ?? "", + package: pkg.name, + role: declaration.role, + declaredIn: entry.path, + scope: entry.directory, + prose: body === "" ? null : body, + }); + } + + continue; + } + + const { tags, diagnostics: tagDiagnostics } = scanTags(contents); + + for (const diagnostic of tagDiagnostics) { + diagnostics.push( + error(entry.path, diagnostic.message, diagnostic.line), + ); + } + + if (tags.layerRoot) { + const id = tags.layerRoot.value; + const segments = id.split("."); + register({ + id, + name: segments[segments.length - 1] ?? "", + package: pkg.name, + role: tags.role?.value ?? "", + declaredIn: entry.path, + scope: entry.directory, + prose: null, + }); + + if (!tags.role) { + diagnostics.push( + error( + entry.path, + `@layerRoot ${id} also needs an @role describing what the layer is responsible for`, + tags.layerRoot.line, + ), + ); + } + } + } + } + + // Pass 2 — resolve every source file to a layer and fold in file-level tags. + const fileLayers = new Map(); + + for (const pkg of packages) { + for (const entry of packageEntries.get(pkg.name) ?? []) { + const isMarkdown = entry.name.toLowerCase().endsWith(".md"); + const isSource = sourceExtensionSet.has(extensionOf(entry.name)); + + if (!isMarkdown && !isSource) { + continue; + } + + const inheritedLayerId = resolveScope(entry.directory, scopes); + + if (isMarkdown) { + if (inheritedLayerId === null) { + continue; + } + const accumulator = accumulators.get(inheritedLayerId); + if (accumulator && accumulator.declaredIn !== entry.path) { + accumulator.references.push(entry.path); + } + continue; + } + + if (inheritedLayerId === null) { + diagnostics.push( + error( + entry.path, + `no layer resolves for this file — declare one on ${entry.directory} or an ancestor (README frontmatter or @layerRoot)`, + ), + ); + continue; + } + + const accumulator = accumulators.get(inheritedLayerId); + + if (!accumulator) { + continue; + } + + // Only line counts are read here: with no per-file tags left in the + // vocabulary, a file's contents say nothing about the architecture beyond + // which layer's folder it sits in. + const contents = await readFile(entry.absolutePath, "utf8"); + + accumulator.files.push(entry.path); + accumulator.lineCount += countNonBlankLines(contents); + fileLayers.set(entry.path, inheritedLayerId); + } + } + + /** + * Every layer is validated here, so the model can never carry an invalid one. + * + * Without this, a `@layerRoot` written without a `@role` reached + * `architectureModelSchema.parse` in `build.ts` and threw a Zod stack trace, + * which replaced the diagnostic naming the file that needed fixing. A + * malformed layer id took the same route. Reporting the file and dropping the + * layer keeps the failure legible, and the build refuses to write anyway. + */ + const layers: Layer[] = [...accumulators.values()] + .flatMap((accumulator) => { + const candidate = { + id: accumulator.id, + name: accumulator.name, + parent: parentLayerId(accumulator.id), + package: accumulator.package, + role: accumulator.role, + declaredIn: accumulator.declaredIn, + prose: accumulator.prose, + references: accumulator.references.sort((left, right) => + left.localeCompare(right), + ), + files: accumulator.files, + fileCount: accumulator.files.length, + lineCount: accumulator.lineCount, + }; + + const parsed = layerSchema.safeParse(candidate); + + if (!parsed.success) { + for (const issue of parsed.error.issues) { + diagnostics.push( + error( + accumulator.declaredIn, + `layer \`${accumulator.id}\` is not valid: ${issue.path.join(".")} ${issue.message}`, + ), + ); + } + } + + // Kept even when invalid. The diagnostic above names the file to fix, and + // the build refuses to write while any error stands, so the model is never + // published. Dropping the layer instead would orphan its files and bury + // the real message under a list of files that no longer resolve. + return [candidate]; + }) + .sort((left, right) => left.id.localeCompare(right.id)); + + return { layers, fileLayers, diagnostics }; +}; diff --git a/libs/@local/petrinaut-arch-docs/src/frontmatter.test.ts b/libs/@local/petrinaut-arch-docs/src/frontmatter.test.ts new file mode 100644 index 00000000000..9293bd17264 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/frontmatter.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; + +import { parseFrontmatter, parseFrontmatterRecord } from "./frontmatter"; + +describe("parseFrontmatterRecord", () => { + /** + * Both readers go through this, so the cases below are the ones that used to + * differ between the YAML parse and a line-splitting one. + */ + it("strips a trailing comment from a value", () => { + const { record } = parseFrontmatterRecord(`--- +attachTo: core.simulation # a layer declared in the source +--- +`); + + expect(record?.attachTo).toBe("core.simulation"); + }); + + it("keeps a hash inside a quoted value", () => { + const { record } = parseFrontmatterRecord(`--- +title: "Frame #3" +--- +`); + + expect(record?.title).toBe("Frame #3"); + }); + + it("reports a `layer` key even beside unrelated page keys", () => { + const { record } = parseFrontmatterRecord(`--- +title: A guide +layer: core.sneaky +role: should be rejected +--- +`); + + expect(record).not.toBeNull(); + expect("layer" in (record ?? {})).toBe(true); + }); + + it("preserves the type YAML inferred", () => { + const { record } = parseFrontmatterRecord(`--- +sidebar_order: 10 +--- +`); + + expect(record?.sidebar_order).toBe(10); + }); + + it("treats a non-mapping document as absent", () => { + expect( + parseFrontmatterRecord("---\n- one\n- two\n---\n").record, + ).toBeNull(); + }); + + it("reports unreadable YAML", () => { + const { record, errors } = parseFrontmatterRecord( + "---\nlayer: [unclosed\n---\n", + ); + + expect(record).toBeNull(); + expect(errors[0]).toContain("invalid YAML"); + }); +}); + +describe("parseFrontmatter", () => { + it("reads a layer declaration and keeps the prose body", () => { + const { declaration, body, errors } = parseFrontmatter(`--- +layer: core.simulation.monte-carlo +role: Runs many bounded-memory simulations +--- + +# Monte Carlo + +Runs batches. +`); + + expect(errors).toEqual([]); + expect(declaration).toEqual({ + layer: "core.simulation.monte-carlo", + role: "Runs many bounded-memory simulations", + }); + expect(body).toBe("# Monte Carlo\n\nRuns batches."); + }); + + it("rejects an unknown key on a declaration, catching typos", () => { + const { declaration, errors } = parseFrontmatter(`--- +layer: core.thing +role: Does a thing +rol: Does a thing +--- +`); + + expect(declaration).toBeNull(); + expect(errors).toHaveLength(1); + }); + + it("treats a README with no frontmatter as prose only", () => { + const { declaration, body, errors } = parseFrontmatter( + "# HIR\n\nThe compiler pipeline.\n", + ); + + expect(declaration).toBeNull(); + expect(errors).toEqual([]); + expect(body).toBe("# HIR\n\nThe compiler pipeline."); + }); + + it("ignores frontmatter that is unrelated to architecture", () => { + const { declaration, errors } = parseFrontmatter(`--- +title: Some page +sidebar_position: 3 +--- +`); + + expect(declaration).toBeNull(); + expect(errors).toEqual([]); + }); + + it("flags a half-written declaration missing its layer key", () => { + const { declaration, errors } = parseFrontmatter(`--- +role: Does a thing +boundaries: + - kind: worker + note: something +--- +`); + + expect(declaration).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("no `layer` key"); + }); + + it("requires a role alongside a layer", () => { + const { declaration, errors } = parseFrontmatter(`--- +layer: core.thing +--- +`); + + expect(declaration).toBeNull(); + expect(errors.join(" ")).toContain("role"); + }); + + it("reports malformed YAML instead of throwing", () => { + const { declaration, errors } = parseFrontmatter(`--- +layer: [unclosed +--- +`); + + expect(declaration).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("invalid YAML"); + }); + + it("handles CRLF line endings", () => { + const { declaration } = parseFrontmatter( + "---\r\nlayer: core.thing\r\nrole: Does a thing\r\n---\r\nBody\r\n", + ); + + expect(declaration?.layer).toBe("core.thing"); + }); +}); diff --git a/libs/@local/petrinaut-arch-docs/src/frontmatter.ts b/libs/@local/petrinaut-arch-docs/src/frontmatter.ts new file mode 100644 index 00000000000..f96e33eb552 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/frontmatter.ts @@ -0,0 +1,140 @@ +/** + * Layer declarations read from folder `README.md` frontmatter. + * + * A README is the natural home for folder-level architecture metadata: the + * frontmatter declares the layer, and the prose below it becomes the layer's + * page body. Several Petrinaut folders already have READMEs describing exactly + * this, so declaring a layer there costs a few lines of frontmatter rather than + * a new document. + * + * A README *without* a `layer` key is not a declaration — it stays an ordinary + * document and is surfaced as a reference on whichever layer it falls under. + */ + +import { load } from "js-yaml"; +import { z } from "zod"; + +const frontmatterPattern = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/u; + +/** + * Strict: a declaration is exactly a layer id and a role. + * + * An unknown key next to a `layer` is a typo or a leftover from a vocabulary + * this version does not have, and either is worth a build error rather than + * silence — a misspelled `role` would otherwise leave the layer with no + * responsibility statement and no complaint. + */ +export const layerDeclarationSchema = z + .object({ + /** Dotted layer id this folder and its descendants belong to. */ + layer: z.string().min(1), + /** One-line responsibility statement. */ + role: z.string().min(1), + }) + .strict(); + +export type LayerDeclaration = z.infer; + +export interface FrontmatterRecord { + /** The mapping as YAML read it, or null when there is no frontmatter. */ + record: Record | null; + /** Markdown body with frontmatter removed. */ + body: string; + errors: string[]; +} + +export interface FrontmatterResult { + /** Present only when the frontmatter carried a `layer` key. */ + declaration: LayerDeclaration | null; + /** Markdown body with frontmatter removed. */ + body: string; + errors: string[]; +} + +/** + * Reads the frontmatter block as YAML, with no schema applied. + * + * Every caller goes through here, so a page's metadata and the check that it + * does not declare a layer see the same mapping. Reading it twice, once as YAML + * and once by splitting lines, made `attachTo: core.simulation # comment` parse + * differently in the two places, and let a `layer` key hide behind an unrelated + * key such as `title`. + */ +export const parseFrontmatterRecord = (markdown: string): FrontmatterRecord => { + const match = frontmatterPattern.exec(markdown); + + if (!match) { + return { record: null, body: markdown.trim(), errors: [] }; + } + + const body = markdown.slice(match[0].length).trim(); + + let parsed: unknown; + try { + parsed = load(match[1] ?? ""); + } catch (cause) { + return { + record: null, + body, + errors: [ + `invalid YAML frontmatter: ${cause instanceof Error ? cause.message : String(cause)}`, + ], + }; + } + + // A scalar or a list is valid YAML but not a mapping, so there are no keys to + // read. Treated as absent rather than as an error, matching a file with no + // frontmatter at all. + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return { record: null, body, errors: [] }; + } + + return { record: parsed as Record, body, errors: [] }; +}; + +/** + * Keys that only make sense as part of a layer declaration. Seeing one without + * a `layer` key almost always means the declaration is half-written, so it is + * reported rather than ignored. + */ +const declarationOnlyKeys = new Set(["role"]); + +export const parseFrontmatter = (markdown: string): FrontmatterResult => { + const { record, body, errors } = parseFrontmatterRecord(markdown); + + if (record === null) { + return { declaration: null, body, errors }; + } + + if (!("layer" in record)) { + const strayKeys = Object.keys(record).filter((key) => + declarationOnlyKeys.has(key), + ); + + return { + declaration: null, + body, + errors: + strayKeys.length > 0 + ? [ + `frontmatter has ${strayKeys.map((key) => `\`${key}\``).join(", ")} but no \`layer\` key, so it does not declare a layer`, + ] + : [], + }; + } + + const result = layerDeclarationSchema.safeParse(record); + + if (!result.success) { + return { + declaration: null, + body, + errors: result.error.issues.map( + (issue) => + `${issue.path.length > 0 ? `${issue.path.join(".")}: ` : ""}${issue.message}`, + ), + }; + } + + return { declaration: result.data, body, errors: [] }; +}; diff --git a/libs/@local/petrinaut-arch-docs/src/graph.ts b/libs/@local/petrinaut-arch-docs/src/graph.ts new file mode 100644 index 00000000000..617bf6844f4 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/graph.ts @@ -0,0 +1,292 @@ +/** + * Turns the real TypeScript import graph into layer-level edges. + * + * dependency-cruiser supplies the file-level truth; the layer assignment comes + * from `extract.ts`. Aggregating one against the other is what makes the + * diagrams trustworthy: an edge appears because imports exist, never because + * someone drew it. + * + * Package subpath aliases are derived from each package's `exports` map rather + * than hand-listed, so a new entry point cannot drop out of the graph unnoticed. + * The previous script hard-coded seven of `petrinaut-core`'s ten entry points, + * which meant imports through `./ai`, `./optimization` and `./compiled-model` + * resolved to nothing and vanished from the diagram. An `exports` subpath that + * no longer resolves is an error here for the same reason: the failure is a loss + * of coverage, and coverage that goes missing without complaint is what this + * package exists to prevent. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { cruise, type ICruiseResult, type IModule } from "dependency-cruiser"; +import extractTSConfig from "dependency-cruiser/config-utl/extract-ts-config"; + +import { error, type Diagnostic } from "./diagnostics"; +import { toPosix } from "./paths"; +import { + exclusionPattern, + sourceExtensions, + sourceRootOf, + sourceRootPattern, +} from "./scope"; + +import type { ArchitecturePackage, Edge, Layer } from "./model"; + +/** How many representative file pairs to record per edge. */ +const examplesPerEdge = 3; + +interface Alias { + alias: string; + name: string; + onlyModule: true; +} + +/** + * Maps a package's `exports` targets back to their source entry files. + * + * `./dist/hir.js` → `src/hir.ts`, and `./dist/react.js` → `src/react/index.ts` + * when the flat file does not exist. Both shapes are in use across the + * Petrinaut packages. + */ +const deriveAliases = ( + repoRoot: string, + pkg: ArchitecturePackage, +): { aliases: Alias[]; diagnostics: Diagnostic[] } => { + const packageRoot = join(repoRoot, pkg.path); + const manifestPath = `${pkg.path}/package.json`; + const diagnostics: Diagnostic[] = []; + + const manifest = JSON.parse( + readFileSync(join(packageRoot, "package.json"), "utf8"), + ) as { exports?: Record }; + + const aliases: Alias[] = []; + + for (const [subpath, target] of Object.entries(manifest.exports ?? {})) { + if (subpath === "./package.json") { + continue; + } + + const distPath = + typeof target === "string" + ? target + : ((target as Record | null)?.import ?? + (target as Record | null)?.default ?? + null); + + if (distPath === null || !distPath.endsWith(".js")) { + // Asset exports such as `./styles.css` have no module counterpart. + continue; + } + + const stem = distPath.replace(/^\.\/dist\//u, "").replace(/\.js$/u, ""); + + const resolved = [ + join(packageRoot, pkg.sourceDirectory, `${stem}.ts`), + join(packageRoot, pkg.sourceDirectory, `${stem}.tsx`), + join(packageRoot, pkg.sourceDirectory, stem, "index.ts"), + join(packageRoot, pkg.sourceDirectory, stem, "index.tsx"), + ].find((candidate) => existsSync(candidate)); + + if (resolved === undefined) { + diagnostics.push( + error( + manifestPath, + `exports \`${subpath}\` but no source entry file resolves for it (looked for ${pkg.sourceDirectory}/${stem}.ts and ${pkg.sourceDirectory}/${stem}/index.ts). Imports through this specifier would be missing from the graph.`, + ), + ); + continue; + } + + aliases.push({ + alias: resolved, + name: subpath === "." ? pkg.name : `${pkg.name}${subpath.slice(1)}`, + onlyModule: true, + }); + } + + // Longest specifier first so `@pkg/workers/lsp` is not shadowed by `@pkg`. + return { + aliases: aliases.sort( + (left, right) => right.name.length - left.name.length, + ), + diagnostics, + }; +}; + +export interface GraphOptions { + repoRoot: string; + /** TypeScript packages only; callers filter before reaching here. */ + packages: ArchitecturePackage[]; + tsconfigPath: string; + ignoredDirectories: string[]; + ignoredFilePattern: RegExp; + /** Repo-relative source file → layer id, from `extract`. */ + fileLayers: Map; + layers: Layer[]; +} + +export interface GraphResult { + edges: Edge[]; + diagnostics: Diagnostic[]; +} + +/** Runs dependency-cruiser over the covered source roots. */ +const cruiseModules = async ( + options: GraphOptions, + aliases: Alias[], +): Promise => { + const result = await cruise( + options.packages.map(sourceRootOf), + { + baseDir: options.repoRoot, + exclude: exclusionPattern(options), + includeOnly: sourceRootPattern(options.packages), + moduleSystems: ["es6"], + tsPreCompilationDeps: true, + }, + { + alias: aliases, + conditionNames: ["types", "import", "default"], + // Mirrors `sourceExtensions`, plus the JavaScript forms a dependency may + // legitimately resolve to inside a covered package. + extensions: [ + ".ts", + ".tsx", + ".mts", + ".cts", + ".js", + ".jsx", + ".mjs", + ".cjs", + ], + }, + { tsConfig: extractTSConfig(options.tsconfigPath) }, + ); + + if (typeof result.output === "string") { + throw new TypeError("dependency-cruiser returned formatted output"); + } + + return (result.output as ICruiseResult).modules; +}; + +/** + * Reports source files the graph reached but no layer claims. + * + * Both stages exclude the same paths, so a source file left over is a real + * disagreement about what is in scope, and every edge touching it is missing + * from the model. Two defects hid here until this check existed: the cruise root + * ignored `sourceDirectory`, and `.mts` files were assigned to layers while + * being unresolvable to the graph. Neither produced a single message. + * + * Restricted to source extensions, because the graph legitimately reaches assets + * no layer should claim. `ui/index.css` is imported by TypeScript and belongs to + * no layer, which is correct: the model describes modules, not the stylesheet one + * of them pulls in. + */ +const checkCoverage = ( + modules: IModule[], + fileLayers: Map, +): Diagnostic[] => + modules + .map((module) => toPosix(module.source)) + .filter( + (file) => + sourceExtensions.some((extension) => file.endsWith(extension)) && + !fileLayers.has(file), + ) + .sort((left, right) => left.localeCompare(right)) + .map((file) => + error( + file, + "the import graph reached this source file but no layer claims it, so its imports are missing from the model. Either it sits outside every declaration's folder, or the extractor and the graph disagree about what counts as source.", + ), + ); + +export const buildGraph = async ( + options: GraphOptions, +): Promise => { + const { repoRoot, fileLayers, layers } = options; + + const aliases: Alias[] = []; + const diagnostics: Diagnostic[] = []; + + for (const pkg of options.packages) { + const derived = deriveAliases(repoRoot, pkg); + aliases.push(...derived.aliases); + diagnostics.push(...derived.diagnostics); + } + + const modules = await cruiseModules(options, aliases); + diagnostics.push(...checkCoverage(modules, fileLayers)); + + interface EdgeAccumulator { + fileDependencies: number; + examples: { from: string; to: string }[]; + } + + const accumulated = new Map(); + + for (const module of modules) { + const fromFile = toPosix(module.source); + const fromLayer = fileLayers.get(fromFile); + + if (fromLayer === undefined) { + continue; + } + + for (const dependency of module.dependencies) { + const toFile = toPosix(dependency.resolved); + const toLayer = fileLayers.get(toFile); + + // Imports landing outside any layer: node_modules, uncovered packages. + // A file *inside* the covered roots is reported by `checkCoverage`. + if (toLayer === undefined || toLayer === fromLayer) { + continue; + } + + // `>` cannot occur in a layer id, which is dot-separated kebab-case, so + // the pair round-trips. An earlier version used a NUL byte for this and + // left two raw NULs in the file, which made every text tool treat the + // source as binary. + const key = `${fromLayer}>${toLayer}`; + const edge = accumulated.get(key) ?? { + fileDependencies: 0, + examples: [], + }; + edge.fileDependencies += 1; + if (edge.examples.length < examplesPerEdge) { + edge.examples.push({ from: fromFile, to: toFile }); + } + accumulated.set(key, edge); + } + } + + const packageOf = new Map(layers.map((layer) => [layer.id, layer.package])); + + const edges: Edge[] = [...accumulated.entries()] + .map(([key, edge]) => { + const [from = "", to = ""] = key.split(">"); + const fromPackage = packageOf.get(from); + const toPackage = packageOf.get(to); + + return { + from, + to, + fileDependencies: edge.fileDependencies, + examples: edge.examples, + crossesPackage: + fromPackage !== undefined && + toPackage !== undefined && + fromPackage !== toPackage, + }; + }) + .sort( + (left, right) => + left.from.localeCompare(right.from) || left.to.localeCompare(right.to), + ); + + return { edges, diagnostics }; +}; diff --git a/libs/@local/petrinaut-arch-docs/src/index.ts b/libs/@local/petrinaut-arch-docs/src/index.ts new file mode 100644 index 00000000000..0ea2dbaeec2 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/index.ts @@ -0,0 +1,5 @@ +/** + * The one type a host of the bundle needs: the shape of `manifest.json`. + */ + +export type { BundleManifest } from "./emit/bundle-outputs"; diff --git a/libs/@local/petrinaut-arch-docs/src/model.ts b/libs/@local/petrinaut-arch-docs/src/model.ts new file mode 100644 index 00000000000..2b212ac4f78 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/model.ts @@ -0,0 +1,120 @@ +/** + * The architecture model — the schema every consumer reads. + * + * Consumers read `architecture.json` rather than re-parsing the codebase, so + * this is the public contract; bump `ARCHITECTURE_MODEL_VERSION` on a breaking + * shape change so they fail loudly rather than mis-reading fields. + */ + +import { z } from "zod"; + +export const ARCHITECTURE_MODEL_VERSION = 1; + +/** + * A layer: one node in the architecture, and one page in the docs. + * + * `id` is dotted and hierarchical (`core.simulation.monte-carlo`). Every + * ancestor segment must itself be a declared layer — the checks enforce this so + * the taxonomy cannot grow implicit holes. + */ +export const layerSchema = z.object({ + id: z + .string() + .regex( + /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)*$/u, + "layer ids are dot-separated kebab-case segments", + ), + name: z.string().min(1), + parent: z.string().nullable(), + package: z.string().min(1), + /** One-line statement of what this layer is responsible for. */ + role: z.string().min(1), + /** Repo-relative path of the declaring `README.md` or entry file. */ + declaredIn: z.string().min(1), + /** Prose body of the declaring README, if any — becomes the page body. */ + prose: z.string().nullable(), + /** Other markdown under this layer, e.g. `hir/BUFFER_ABI.md`, linked from its page. */ + references: z.array(z.string().min(1)), + files: z.array(z.string().min(1)), + fileCount: z.number().int().nonnegative(), + /** Total non-blank lines across `files`. */ + lineCount: z.number().int().nonnegative(), +}); + +export type Layer = z.infer; + +/** An aggregated import relationship between two layers. */ +export const edgeSchema = z.object({ + from: z.string().min(1), + to: z.string().min(1), + /** How many file-level imports collapse into this edge. */ + fileDependencies: z.number().int().positive(), + /** A few representative imports, so a reader can jump to real code. */ + examples: z.array(z.object({ from: z.string(), to: z.string() })), + /** + * Whether the two layers live in different workspace packages. + * + * The only fact derived about what an edge crosses. A static import graph + * cannot tell which runtime costs an import incurs: importing a module that + * runs in a worker is how the caller obtains that module, and says nothing + * about whether a thread hop occurs at the call. Package membership is a + * property of the two layers, so it holds whenever it is reported. + */ + crossesPackage: z.boolean(), +}); + +export type Edge = z.infer; + +export const packageSchema = z.object({ + name: z.string().min(1), + /** Repo-relative package root. */ + path: z.string().min(1), + description: z.string(), + language: z.enum(["typescript", "python"]), + /** + * Subdirectory holding the code the architecture describes, relative to + * `path`. Build configuration (`vite.config.ts`, `panda.config.ts`, + * `.storybook/`) sits outside it and is deliberately not part of any layer — + * it configures the build, it is not a piece of the system's design. + */ + sourceDirectory: z.string().min(1).default("src"), +}); + +export type ArchitecturePackage = z.infer; + +/** Config-facing shape, where defaulted fields may be omitted. */ +export type ArchitecturePackageInput = z.input; + +export const architectureModelSchema = z.object({ + version: z.literal(ARCHITECTURE_MODEL_VERSION), + packages: z.array(packageSchema), + layers: z.array(layerSchema), + edges: z.array(edgeSchema), + /** Echoed from config so consumers can render the rules without reading it. */ + rules: z.array( + z.object({ + from: z.string().min(1), + to: z.string().min(1), + reason: z.string().min(1), + }), + ), +}); + +export type ArchitectureModel = z.infer; + +/** Derive the parent layer id from a dotted id (`a.b.c` → `a.b`). */ +export const parentLayerId = (id: string): string | null => { + const lastDot = id.lastIndexOf("."); + return lastDot === -1 ? null : id.slice(0, lastDot); +}; + +/** All ancestor ids of a dotted id, nearest first. */ +export const ancestorLayerIds = (id: string): string[] => { + const ancestors: string[] = []; + let current = parentLayerId(id); + while (current !== null) { + ancestors.push(current); + current = parentLayerId(current); + } + return ancestors; +}; diff --git a/libs/@local/petrinaut-arch-docs/src/paths.ts b/libs/@local/petrinaut-arch-docs/src/paths.ts new file mode 100644 index 00000000000..8af5a139881 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/paths.ts @@ -0,0 +1,14 @@ +/** + * Path helpers shared by every stage that reports a file. + * + * Repo-relative, posix-separated paths are the model's currency: they appear in + * `declaredIn`, in diagnostics, in edge examples and in source links. The + * conversion lived in three modules separately, which is three chances for the + * model to disagree with itself about what a path looks like on Windows. + */ + +import { posix, sep } from "node:path"; + +/** Converts a platform path to the posix form the model always uses. */ +export const toPosix = (path: string): string => + path.split(sep).join(posix.sep); diff --git a/libs/@local/petrinaut-arch-docs/src/scope.test.ts b/libs/@local/petrinaut-arch-docs/src/scope.test.ts new file mode 100644 index 00000000000..e421551e433 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/scope.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; + +import { + exclusionPattern, + sourceExtensions, + sourceRootOf, + sourceRootPattern, +} from "./scope"; + +import type { ArchitecturePackage } from "./model"; + +/** + * These four functions exist because the extractor and the graph builder used to + * answer the same questions separately and drift apart. The tests pin the answers + * rather than the callers, so a future divergence shows up here first. + */ + +const pkg = ( + overrides: Partial & Pick, +): ArchitecturePackage => ({ + name: "@test/pkg", + description: "", + language: "typescript", + sourceDirectory: "src", + ...overrides, +}); + +describe("sourceRootOf", () => { + it("honours a non-default sourceDirectory", () => { + expect(sourceRootOf(pkg({ path: "libs/a", sourceDirectory: "lib" }))).toBe( + "libs/a/lib", + ); + }); + + it("defaults to src", () => { + expect(sourceRootOf(pkg({ path: "libs/a" }))).toBe("libs/a/src"); + }); +}); + +describe("sourceRootPattern", () => { + it("anchors at the start and requires a path separator", () => { + const pattern = new RegExp( + sourceRootPattern([pkg({ path: "libs/a" }), pkg({ path: "libs/b" })]), + "u", + ); + + expect(pattern.test("libs/a/src/index.ts")).toBe(true); + expect(pattern.test("libs/b/src/index.ts")).toBe(true); + // A sibling whose name merely starts the same must not match. + expect(pattern.test("libs/a/srcs/index.ts")).toBe(false); + expect(pattern.test("other/libs/a/src/index.ts")).toBe(false); + }); + + it("escapes regular-expression characters in a package path", () => { + const pattern = new RegExp( + sourceRootPattern([pkg({ path: "libs/@scope/a.b" })]), + "u", + ); + + expect(pattern.test("libs/@scope/a.b/src/index.ts")).toBe(true); + expect(pattern.test("libs/@scope/aXb/src/index.ts")).toBe(false); + }); +}); + +describe("exclusionPattern", () => { + const pattern = new RegExp( + exclusionPattern({ + ignoredDirectories: ["node_modules", "__fixtures__"], + ignoredFilePattern: /(?:\.test\.ts$|\.d\.ts$)/u, + }), + "u", + ); + + it("excludes an ignored directory at any depth", () => { + expect(pattern.test("libs/a/src/__fixtures__/net.ts")).toBe(true); + expect(pattern.test("libs/a/src/deep/node_modules/x.ts")).toBe(true); + }); + + it("excludes ignored files", () => { + expect(pattern.test("libs/a/src/thing.test.ts")).toBe(true); + expect(pattern.test("libs/a/src/thing.d.ts")).toBe(true); + }); + + it("keeps ordinary source", () => { + expect(pattern.test("libs/a/src/thing.ts")).toBe(false); + // A directory name appearing as a file stem is not an exclusion. + expect(pattern.test("libs/a/src/node_modules.ts")).toBe(false); + }); +}); + +describe("sourceExtensions", () => { + it("covers the module extensions TypeScript emits from", () => { + expect([...sourceExtensions]).toEqual([".ts", ".tsx", ".mts", ".cts"]); + }); +}); diff --git a/libs/@local/petrinaut-arch-docs/src/scope.ts b/libs/@local/petrinaut-arch-docs/src/scope.ts new file mode 100644 index 00000000000..fa0482384e2 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/scope.ts @@ -0,0 +1,56 @@ +/** + * What counts as source, and which files are in scope. + * + * The extractor and the graph builder each walk the packages, and they have to + * agree on the answer. When they disagreed, the graph silently lost edges: the + * cruise root was a hardcoded `src` while the extractor honoured + * `sourceDirectory`, and `.mts` files were assigned to layers by one and left + * unresolvable by the other. Both are read from here now, so a discrepancy has + * to be introduced deliberately rather than by editing one module. + */ + +import { posix } from "node:path"; + +import type { ArchitecturePackage } from "./model"; + +/** + * Extensions treated as architecture source. + * + * `.js` variants are absent on purpose: these packages are TypeScript, and a + * committed `.js` file under `src` would be build output that no layer should + * claim. + */ +export const sourceExtensions = [".ts", ".tsx", ".mts", ".cts"] as const; + +/** Repo-relative, posix source root for a package. */ +export const sourceRootOf = (pkg: ArchitecturePackage): string => + posix.join(pkg.path, pkg.sourceDirectory); + +const escapeForRegExp = (text: string): string => + text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + +/** + * Anchored pattern matching every in-scope source root. + * + * Passed to dependency-cruiser as `includeOnly`, so the graph never reaches + * outside the packages the model covers. + */ +export const sourceRootPattern = (packages: ArchitecturePackage[]): string => + `^(?:${packages.map((pkg) => escapeForRegExp(sourceRootOf(pkg))).join("|")})/`; + +/** + * Everything both stages skip, as one pattern. + * + * The extractor skips ignored directories during its walk and ignored files + * afterwards. dependency-cruiser needs the same two facts as a single regex, or + * it reports modules the extractor never considered and every one of them looks + * like a coverage gap. + */ +export const exclusionPattern = (options: { + ignoredDirectories: string[]; + ignoredFilePattern: RegExp; +}): string => { + const directories = options.ignoredDirectories.map(escapeForRegExp).join("|"); + + return `(?:/(?:${directories})/)|(?:${options.ignoredFilePattern.source})`; +}; diff --git a/libs/@local/petrinaut-arch-docs/src/tags.test.ts b/libs/@local/petrinaut-arch-docs/src/tags.test.ts new file mode 100644 index 00000000000..8ff60fe91bf --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/tags.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; + +import { scanTags } from "./tags"; + +describe("scanTags", () => { + it("reads a layer declaration from a file header", () => { + const { tags, diagnostics } = scanTags(`/** + * @layerRoot core.simulation.monte-carlo + * @role Runs many bounded-memory simulations + */ +export const run = () => {}; +`); + + expect(diagnostics).toEqual([]); + expect(tags.layerRoot?.value).toBe("core.simulation.monte-carlo"); + expect(tags.layerRoot?.line).toBe(2); + expect(tags.role?.value).toBe("Runs many bounded-memory simulations"); + }); + + it("continues a tag's text across wrapped lines", () => { + const { tags } = scanTags(`/** + * @role Runs many bounded-memory simulations, so a long experiment + * never grows the heap regardless of how many frames it computes + */`); + + expect(tags.role?.value).toBe( + "Runs many bounded-memory simulations, so a long experiment never grows the heap regardless of how many frames it computes", + ); + }); + + it("ends a tag's text at a blank line", () => { + const { tags } = scanTags(`/** + * @role Compiles user code + * + * Extra prose that is not part of the role. + */`); + + expect(tags.role?.value).toBe("Compiles user code"); + }); + + it("reports a duplicated singular tag rather than silently overwriting", () => { + const { tags, diagnostics } = scanTags(`/** + * @role First + * @role Second + */`); + + expect(tags.role?.value).toBe("First"); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toContain("duplicate @role"); + }); + + it("suggests a correction for a miscased tag", () => { + const { diagnostics } = scanTags(`/** + * @LayerRoot core.thing + */`); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toBe( + "unknown tag @LayerRoot; did you mean @layerRoot?", + ); + }); + + it("ignores standard JSDoc tags", () => { + const { tags, diagnostics } = scanTags(`/** + * Does a thing. + * + * @param input the thing + * @returns the other thing + * @see somewhere + */`); + + expect(diagnostics).toEqual([]); + expect(tags.layerRoot).toBeNull(); + expect(tags.role).toBeNull(); + }); + + it("ignores tags outside the vocabulary", () => { + const { tags, diagnostics } = scanTags(`/** + * @layerRoot core.lsp + * @role Language-server client + * @boundary thread — requests reach the server over a worker transport + * @internal + */`); + + expect(diagnostics).toEqual([]); + expect(tags.layerRoot?.value).toBe("core.lsp"); + expect(tags.role?.value).toBe("Language-server client"); + }); + + it("does not treat a tag mentioned mid-sentence as a tag", () => { + const { tags, diagnostics } = scanTags(`/** + * Layers are declared with @layerRoot on a folder entry file. + */`); + + expect(tags.layerRoot).toBeNull(); + expect(diagnostics).toEqual([]); + }); + + it("reports line numbers relative to the whole file", () => { + const { tags } = scanTags(`line one +line two +line three + +/** + * @layerRoot core.thing + */`); + + expect(tags.layerRoot?.line).toBe(6); + }); +}); diff --git a/libs/@local/petrinaut-arch-docs/src/tags.ts b/libs/@local/petrinaut-arch-docs/src/tags.ts new file mode 100644 index 00000000000..4813ec0d199 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/src/tags.ts @@ -0,0 +1,206 @@ +/** + * The in-code tag vocabulary, and the scanner that reads it. + * + * A layer is declared either here, with `@layerRoot` on a folder's primary + * file, or in a folder `README.md`'s frontmatter (see `frontmatter.ts`), whose + * prose then becomes the layer's page. Files with no tags inherit from the + * nearest declaring ancestor. + * + * The vocabulary is deliberately two tags: an id and a one-line role. Both are + * needed to place a layer in the graph and to label it — anything more is a + * claim the generator cannot check, and this version does not make claims it + * cannot keep. + * + * Tags are recognised only at the start of a line inside a block comment, so a + * tag named in running prose is not picked up. + * + * Block comments are matched by pattern rather than by lexing the file, so a + * string or template literal containing a whole comment block would be read as + * one. Using the TypeScript scanner would remove that case, at the cost of + * parsing every file. Worth revisiting if a package starts embedding annotated + * code samples in string literals. + */ + +/** A tag occurrence, with the line it was found on for error reporting. */ +export interface TagValue { + value: string; + line: number; +} + +export interface ParsedTags { + /** + * `@layerRoot` — declares that this file's folder *and its descendants* form + * the named layer. The alternative to a README declaration, for folders that + * have a barrel entry file but no README. + */ + layerRoot: TagValue | null; + /** `@role` — one-line statement of what the layer is responsible for. */ + role: TagValue | null; +} + +export interface TagDiagnostic { + line: number; + message: string; +} + +export interface TagScanResult { + tags: ParsedTags; + diagnostics: TagDiagnostic[]; +} + +const emptyTags = (): ParsedTags => ({ + layerRoot: null, + role: null, +}); + +/** Tags that may appear at most once per file. */ +const singularTags = ["layerRoot", "role"] as const; + +type SingularTag = (typeof singularTags)[number]; + +const knownTagNames = new Set(singularTags); + +const blockCommentPattern = /\/\*\*[\s\S]*?\*\//gu; + +/** + * Byte offsets of the start of each line, so an offset can be turned into a + * 1-based line number by binary search rather than a rescan per comment. + */ +const lineStartOffsets = (text: string): number[] => { + const starts = [0]; + for (let position = 0; position < text.length; position += 1) { + if (text[position] === "\n") { + starts.push(position + 1); + } + } + return starts; +}; + +const lineNumberAt = (lineStarts: number[], offset: number): number => { + let low = 0; + let high = lineStarts.length - 1; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if ((lineStarts[middle] ?? 0) <= offset) { + low = middle; + } else { + high = middle - 1; + } + } + return low + 1; +}; + +/** + * Strips the comment delimiters and the leading `*` gutter, returning body + * lines paired with their line number in the original file. + */ +const commentBodyLines = ( + comment: string, + startLine: number, +): { text: string; line: number }[] => + comment + .replace(/^\/\*\*/u, "") + .replace(/\*\/$/u, "") + .split("\n") + .map((rawLine, offset) => ({ + text: rawLine.replace(/^\s*\*\s?/u, ""), + line: startLine + offset, + })); + +interface RawTag { + name: string; + text: string; + line: number; +} + +/** + * Collects tags from one comment body. A tag's text continues onto following + * lines until the next tag or the end of the comment, so multi-line notes stay + * readable in source. + */ +const collectRawTags = (lines: { text: string; line: number }[]): RawTag[] => { + const tags: RawTag[] = []; + let current: RawTag | null = null; + + for (const { text, line } of lines) { + const match = /^@([A-Za-z][A-Za-z0-9]*)\s*(.*)$/u.exec(text.trim()); + + if (match) { + if (current) { + tags.push(current); + } + current = { name: match[1] ?? "", text: match[2] ?? "", line }; + continue; + } + + if (current) { + const continuation = text.trim(); + if (continuation === "") { + // A blank line ends the tag's text but not the comment. + tags.push(current); + current = null; + } else { + current.text = `${current.text} ${continuation}`.trim(); + } + } + } + + if (current) { + tags.push(current); + } + + return tags; +}; + +/** Reads every architecture tag out of a source file's block comments. */ +export const scanTags = (sourceText: string): TagScanResult => { + const tags = emptyTags(); + const diagnostics: TagDiagnostic[] = []; + + const assignSingular = (name: SingularTag, { text, line }: RawTag): void => { + if (text === "") { + diagnostics.push({ line, message: `@${name} requires a value` }); + return; + } + if (tags[name] !== null) { + diagnostics.push({ + line, + message: `duplicate @${name} (already set on line ${tags[name]?.line})`, + }); + return; + } + tags[name] = { value: text, line }; + }; + + const lineStarts = lineStartOffsets(sourceText); + + for (const match of sourceText.matchAll(blockCommentPattern)) { + const startLine = lineNumberAt(lineStarts, match.index); + const rawTags = collectRawTags(commentBodyLines(match[0], startLine)); + + for (const rawTag of rawTags) { + const { name, line } = rawTag; + + if (singularTags.includes(name as SingularTag)) { + assignSingular(name as SingularTag, rawTag); + continue; + } + + // Only a miscased version of one of our own tags is reported. Every other + // unknown tag is someone else's — `@param`, `@deprecated`, an eslint + // directive, or one of the annotations this version deliberately does not + // read — and is none of our business. + const suggestion = [...knownTagNames].find( + (known) => known !== name && known.toLowerCase() === name.toLowerCase(), + ); + if (suggestion !== undefined) { + diagnostics.push({ + line, + message: `unknown tag @${name}; did you mean @${suggestion}?`, + }); + } + } + } + + return { tags, diagnostics }; +}; diff --git a/libs/@local/petrinaut-arch-docs/tsconfig.json b/libs/@local/petrinaut-arch-docs/tsconfig.json new file mode 100644 index 00000000000..d25e7719d59 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2024", + "lib": ["ESNext"], + "types": ["node"], + "module": "preserve", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["src", "architecture.config.ts"] +} diff --git a/libs/@local/petrinaut-arch-docs/turbo.json b/libs/@local/petrinaut-arch-docs/turbo.json new file mode 100644 index 00000000000..17273dee65f --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/turbo.json @@ -0,0 +1,20 @@ +{ + "extends": ["//"], + "tasks": { + "doc:architecture": { + // Not cached. Turborepo hashes a task's own package plus its dependencies' + // task *outputs*, and the annotations this reads are plain source comments + // in `@hashintel/petrinaut` and `petrinaut-core` — not any task's output. + // A cached result would therefore survive an annotation change and go + // quietly stale, which is the exact failure this package exists to prevent. + // The run takes a few seconds; correctness is worth more here. + "cache": false, + // Declared so consumers can depend on this task rather than on the + // directory existing, and so `turbo run` prunes it on a clean. + "outputs": ["bundle/**"] + }, + "lint:arch-docs": { + "cache": false + } + } +} diff --git a/yarn.lock b/yarn.lock index 6a68fd61208..1b232a6d5ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6916,7 +6916,6 @@ __metadata: dependencies: "@types/node": "npm:22.18.13" "@typescript/native-preview": "npm:7.0.0-dev.20260511.1" - dependency-cruiser: "npm:18.0.0" elkjs: "npm:0.11.0" immer: "npm:10.1.3" oxlint: "npm:1.63.0" @@ -8681,6 +8680,25 @@ __metadata: languageName: unknown linkType: soft +"@local/petrinaut-arch-docs@workspace:libs/@local/petrinaut-arch-docs": + version: 0.0.0-use.local + resolution: "@local/petrinaut-arch-docs@workspace:libs/@local/petrinaut-arch-docs" + dependencies: + "@hashintel/petrinaut": "workspace:*" + "@hashintel/petrinaut-core": "workspace:*" + "@local/tsconfig": "workspace:*" + "@types/js-yaml": "npm:^4" + "@types/node": "npm:22.18.13" + dependency-cruiser: "npm:18.0.0" + js-yaml: "npm:4.3.1" + oxlint: "npm:1.63.0" + tsx: "npm:4.20.6" + typescript: "npm:5.9.3" + vitest: "npm:4.1.10" + zod: "npm:4.4.3" + languageName: unknown + linkType: soft + "@local/petrinaut-optimizer-client@workspace:*, @local/petrinaut-optimizer-client@workspace:libs/@local/petrinaut-optimizer-client": version: 0.0.0-use.local resolution: "@local/petrinaut-optimizer-client@workspace:libs/@local/petrinaut-optimizer-client"