Add archify-vscode-ext skill: Archify diagrams and models for the Archify Diagram Viewer extension

Model tasks (validate, list, scaffold, resolve), diagram tasks (validate,
render), the bundled Archify 3.0.1 runtime and yaml parser under
scripts/vendor, references for authoring, formats and folders, and the
online-shop example model.
This commit is contained in:
root-at-skic committed 2026-10-19 11:42:06 +03:00
commit b6b5e57922
226 files changed
+67520

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Root At Skic
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.
+20
View File
@@ -0,0 +1,20 @@
# archify-vscode-ext
Agent Skill for Archify diagrams and models as read by the [Archify Diagram Viewer](https://marketplace.visualstudio.com/items?itemName=root-at-skic.archify-vscode-ext) VS Code extension: how to write `.archify` diagrams (architecture, workflow, sequence, dataflow, lifecycle), how to put them together as a model (`main.archify`, shared elements in `*.archify-registry` files, `ref:` components, `diagram:` links, C4-style folders), and tasks that validate, list, scaffold, resolve and render them. The Archify validator and renderer (MIT, by tt-a1i) are bundled under `scripts/vendor`, so nothing else needs to be installed.
Quick start:
```bash
cd scripts
task model:scaffold -- --dir="/path/to/online-shop" --id="online-shop" --name="Online Shop" --c4
task model:validate -- --dir="/path/to/online-shop"
task diagram:render -- --file="/path/to/online-shop/0200-containers/online-shop.archify" --out="/tmp/containers.html"
```
See [SKILL.md](SKILL.md) for the workflow, the task table and the gotchas; `examples/online-shop/` is a complete model that validates.
Requires Node.js 18+, bash and [go-task](https://taskfile.dev). The skill targets the Archify Diagram Viewer extension; a diagram that validates here opens the same way in VS Code.
## Support
If this saves you time, you can buy me a coffee: https://buymeacoffee.com/root.at.skic
+178
View File
@@ -0,0 +1,178 @@
---
name: archify-vscode-ext
description: >
Author, validate, render and manage Archify diagrams (.archify YAML files of the kinds
architecture, workflow, sequence, dataflow and lifecycle) and Archify models: a folder
with main.archify on top, shared elements in *.archify-registry files, ref: components,
diagram: links and C4-style folders, as read by the Archify Diagram Viewer VS Code
extension. Bundles the Archify validator and renderer, so diagrams are checked and
rendered to HTML without VS Code. Use this skill when asked to create, edit, fix,
validate, render or organise .archify files, build a C4 or architecture model as code,
add a shared element or a registry, scaffold a model folder, or explain why a diagram
does not validate, even if the user only says "draw the architecture", "make a diagram
of this system", "put the diagrams together" or "the diagram shows an error".
license: MIT
compatibility: Requires Node.js 18+, bash and go-task (task). No network access; Archify is bundled under scripts/vendor.
metadata:
author: root-at-skic
version: "1.0"
origin-repository: https://gitea.lego-cloud.eu/vscode-extensions-skills/archify-vscode-ext.git
targets: Archify Diagram Viewer (root-at-skic.archify-vscode-ext)
---
# Archify diagrams and models
Write diagrams as text next to the code, in the form the Archify Diagram Viewer extension opens as interactive pictures. One `.archify` file is one diagram. A folder with `main.archify` on top is a model: diagrams that belong together, shared elements defined once in a registry, and links between diagrams.
## Overview
Archify (MIT, by tt-a1i) is the renderer and validator. The extension bundles it; so does this skill (`scripts/vendor/archify`), so every task here runs on plain Node and needs nothing installed. The model layer (registry, `ref`, `diagram:` links) is the extension's; this skill resolves it the same way the extension does before Archify sees a diagram. Every task prints YAML on stdout (`success: true|false` first) and logs to stderr.
All tasks run from this skill's `scripts/` folder:
```bash
cd <this-skill>/scripts
task --list-all
```
| Task | What it does |
|---|---|
| `task model:validate -- --dir="<model or file inside>"` | Registry, every diagram (refs resolved, Archify validator), every `diagram:` link. Exit 1 on any error. Works on a single diagram outside a model too. |
| `task model:list -- --dir="<model>"` | What the model page shows: metadata, diagrams by folder, registry elements with "used in N diagrams". |
| `task model:scaffold -- --dir="<folder>" --id="<id>" --name="<name>" [--description="…"] [--c4]` | Creates `main.archify`; with `--c4` also `registry/` and the `0100-context`, `0200-containers`, `0300-components` folders with starter diagrams. Never overwrites. |
| `task model:resolve -- --file="<diagram>" [--out="<file.json>"]` | The Archify JSON the extension hands to Archify, for the plain Archify CLI or CI. |
| `task diagram:validate -- --file="<diagram>"` | One diagram through Archify (refs resolved when inside a model). |
| `task diagram:render -- --file="<diagram>" --out="<file.html>"` | Standalone HTML page for people without VS Code. |
| `task test` | The skill's own checks against `examples/online-shop` and a scaffolded model. |
| `task sync` | Refresh `scripts/vendor/archify` from an Archify checkout (maintainers only). |
Options for validate and render: `--quality=showcase` (stricter composition checks), `--repo-root=<checkout>` for diagrams with `meta.repository` and `sources`.
## Workflow
### 1. Diagram or model?
- One picture, no shared things: a single `.archify` file anywhere. Skip to step 4 and write inline elements (`id`, `type`, `label`).
- Several pictures of one system, the same elements in more than one of them, levels (context, containers, components), "click the API to see inside": a model. Start with step 2.
A diagram inside a model is still a plain diagram: it opens, renders and exports on its own.
### 2. Start a model
New folder:
```bash
task model:scaffold -- --dir="/path/to/online-shop" --id="online-shop" --name="Online Shop" --description="Web shop, API, workers and stores." --c4
```
Existing folder of diagrams: add `main.archify` with the `model` block (see `references/formats.md`); every `.archify` below it is now part of the model. `--c4` is a convention, not a requirement: any folders, any depth.
The model's root is the nearest folder above a file that holds `main.archify`. Nothing else marks it. `main.archify` may also hold a diagram of its own (the overview), below the `model` block.
### 3. Add a shared element
Put it in a `*.archify-registry` file under the root (one file or many; `registry/people`, `registry/systems`, `registry/containers` is the C4 split). The key is the id, unique across all registry files:
```yaml
schema_version: 1
elements:
api:
type: backend # Archify component type, fixed for the element
label: API
sublabel: Node.js
brand: node-js # optional, an Archify brand id
description: >
Owns authentication, orders and the catalogue.
diagram: 0300-components/api.archify # the diagram that explains it, relative to the root
```
Fields that reach Archify: `type`, `label`, `sublabel`, `tag`, `icon`, `brand`, `sources`. `description` and `diagram` belong to the model only. Write `description` with `>` (a folded block): a plain scalar with `: ` in it is a YAML error.
### 4. Write a diagram
```yaml
schema_version: 1
diagram_type: architecture
meta:
title: Containers
output: containers.html # required by Archify; relative .html path
components:
- ref: api # from the registry; id defaults to "api"
pos: [460, 160]
size: [130, 60]
- ref: db
label: Orders DB # label, sublabel, tag may be overridden
pos: [670, 260]
size: [130, 60]
- id: cron # unshared, inline, as in any diagram
type: backend
label: Nightly job
pos: [670, 460]
size: [130, 60]
connections:
- { from: api, to: db, label: SQL }
```
- `ref` plus the diagram's own geometry (`pos`, `size`, or `row`/`col`). `id: db-replica` with `ref: db` draws the same element under another id.
- Override only `label`, `sublabel`, `tag`. `type`, `brand`, `icon`, `sources` come from the registry; if they differ, it is a different element.
- A component may add `diagram: <path>` to link to another diagram; it wins over the registry's link.
- Connections, boundaries, lanes and everything else are plain Archify and use the ids.
Read `references/authoring.md` before writing a diagram of a kind you have not written in this session: the five kinds, fields, the position grid, labels, boundaries and the validator's error codes with their fixes.
### 5. Validate after every change
```bash
task model:validate -- --dir="/path/to/online-shop"
```
Read the output top down:
- `summary.errors` and `summary.warnings`; `diagramsOk` of `diagrams`.
- `problems`: model-level (duplicate ids across registries, a nested `main.archify`, bad links in registries).
- `diagrams[].problems`: per file, each with `file`, `line`, `pointer` (JSON pointer into the file, `/components/3/ref`), `code` for Archify's own diagnostics, and the message. Archify messages often end with a suggested fix (`move "db" pos to [178, 70]`, `labelAt`); apply that value rather than guessing.
Fix in this order: YAML syntax, registry problems, unknown refs and forbidden overrides, schema errors (`schema/…`), node overlap and out-of-range placement, edges through nodes, crossings and corridors, label clearance. Run again until `success: true`. For one file, `task diagram:validate -- --file=…` is faster. `--quality=showcase` adds composition warnings; use it before a diagram is shown to people.
### 6. Render for people without VS Code
```bash
task diagram:render -- --file="/path/to/online-shop/0200-containers/online-shop.archify" --out="/tmp/containers.html"
```
A standalone HTML page with Archify's viewer (focus, search, export). Links to other diagrams are not followed outside VS Code. `task model:resolve -- --file=… --out=x.json` gives the resolved JSON when someone wants to run the plain Archify CLI (`node scripts/vendor/archify/bin/archify.mjs validate architecture x.json --json`).
## Folder conventions
```
online-shop/
main.archify model block (+ optional overview diagram)
registry/*.archify-registry shared elements, split any way
0100-context/… groups are folders; numbers only sort them
0200-containers/…
0300-components/…
```
Diagrams next to `main.archify` are listed first, ungrouped; deeper ones by folder path. Read `references/folders.md` for the layouts (single diagram, smallest model, grouped, C4 with 0400-code) and how levels hang together through `diagram:` links.
## Gotchas
- **Colons in plain scalars.** `description: Orders: the source of truth` is a YAML error (nested mapping). Use `description: >` and indent the text, or quote it. The same for labels with `: `.
- **The `model` block must not reach Archify.** Archify's schemas set `additionalProperties: false` at every level; any unknown key (`model`, `ref`, `description`, `diagram`, a typo like `colour`) fails as `schema/additionalProperties`. The tasks strip the model keys; everything else must be a schema field.
- **`type` and `brand` cannot be overridden** on a `ref` component. The validator reports `"type" of shared element "db" cannot be overridden`. Change the registry or use a different element.
- **Ids are unique per diagram.** Two `ref: db` entries in one diagram clash; give the second `id: db-replica`.
- **`ref` outside a model is an error.** Without `main.archify` above the file there is no registry; the extension would show the component as `external` with the ref name. Define it inline or create the model.
- **Overlapping labels need `labelAt`** (or `labelDx`/`labelDy`). Archify reports label clearance with a suggested point; set exactly that. Shortening the label is the last resort, never deleting it.
- **`meta.output` is required** and must be a relative POSIX `.html` path (`containers.html`); it is not where the render task writes, `--out` is.
- **Boxes need clear gaps.** Components closer than 8px fail; labels wider than the box fail (`6.5px` per character plus 13). The grid in `references/authoring.md` keeps a standard layout clear.
- **Links are relative to the model root**, never to the file, and must end in `.archify` and exist. `..` is refused.
- **A second `main.archify` deeper down** is reported, not nested. One model per tree.
- **Diagram kinds other than architecture** take `ref` too (workflow and dataflow `nodes`, sequence `participants`, lifecycle `states`), but the registry `type` must fit that kind: lifecycle states use their own type enum, and sequence participants have no `tag`.
- **No em dashes or smart quotes in ids**; ids match `^[a-zA-Z][a-zA-Z0-9_-]*$`.
## References
- Read `references/authoring.md` when writing or repairing a diagram: kinds, fields, grid, labels, boundaries, brands, validator codes and fixes.
- Read `references/formats.md` when writing `main.archify`, a registry, a `ref` or a link, or when unsure what the tasks strip before Archify runs.
- Read `references/folders.md` when laying out a model folder or explaining how the extension finds the root and groups diagrams.
- `examples/online-shop/` is a complete C4-style model that validates with 0 problems; copy its shape.
@@ -0,0 +1,26 @@
schema_version: 1
diagram_type: architecture
meta:
title: System context
output: context.html
components:
- ref: customer
pos: [40, 60]
size: [130, 60]
- ref: support
pos: [40, 260]
size: [130, 60]
- ref: shop
pos: [280, 160]
size: [150, 60]
- ref: psp
pos: [520, 60]
size: [150, 60]
- ref: email
pos: [520, 260]
size: [150, 60]
connections:
- { from: customer, to: shop, label: buys, variant: emphasis }
- { from: support, to: shop, label: manages orders }
- { from: shop, to: psp, label: charges, variant: security }
- { from: shop, to: email, label: sends mail }
@@ -0,0 +1,39 @@
schema_version: 1
diagram_type: architecture
meta:
title: Containers
output: containers.html
components:
- ref: customer
pos: [40, 160]
size: [130, 60]
- ref: web
pos: [250, 160]
size: [130, 60]
- ref: api
pos: [460, 160]
size: [130, 60]
- ref: cache
pos: [670, 60]
size: [130, 60]
- ref: db
pos: [670, 260]
size: [130, 60]
- ref: queue
pos: [460, 360]
size: [130, 60]
- ref: worker
pos: [670, 460]
size: [130, 60]
boundaries:
- kind: region
label: Online Shop
wraps: [web, api, cache, db, queue, worker]
connections:
- { from: customer, to: web, label: HTTPS, variant: emphasis }
- { from: web, to: api, label: JSON }
- { from: api, to: cache, label: read }
- { from: api, to: db, label: SQL }
- { from: api, to: queue, label: enqueue, labelAt: [560, 290] }
- { from: queue, to: worker, label: poll }
- { from: worker, to: db, label: SQL, labelAt: [735, 420] }
@@ -0,0 +1,42 @@
schema_version: 1
diagram_type: architecture
meta:
title: API components
output: api.html
components:
- ref: web
pos: [40, 160]
size: [130, 60]
- id: auth
type: security
label: Auth
sublabel: sessions, OIDC
pos: [250, 60]
size: [130, 60]
- id: orders
type: backend
label: Orders
pos: [250, 160]
size: [130, 60]
- id: catalogue
type: backend
label: Catalogue
pos: [250, 260]
size: [130, 60]
- ref: db
pos: [460, 160]
size: [130, 60]
- ref: queue
pos: [460, 310]
size: [130, 60]
boundaries:
- kind: region
label: API
wraps: [auth, orders, catalogue]
connections:
- { from: web, to: auth, label: sign in, variant: security }
- { from: web, to: orders, label: place order }
- { from: web, to: catalogue, label: browse }
- { from: orders, to: db, label: SQL }
- { from: catalogue, to: db, label: SQL }
- { from: orders, to: queue, label: enqueue }
+24
View File
@@ -0,0 +1,24 @@
schema_version: 1
model:
id: online-shop
name: Online Shop
description: >
The web shop, its API and workers, and the stores behind them.
Context, containers and components, C4 style.
diagram_type: architecture
meta:
title: Online Shop — overview
output: main.html
components:
- ref: customer
pos: [40, 160]
size: [130, 60]
- ref: shop
pos: [250, 160]
size: [150, 60]
- ref: psp
pos: [480, 160]
size: [150, 60]
connections:
- { from: customer, to: shop, label: buys, variant: emphasis }
- { from: shop, to: psp, label: charges }
@@ -0,0 +1,37 @@
schema_version: 1
elements:
web:
type: frontend
label: Web App
sublabel: React
brand: react
description: The shop as customers see it; talks only to the API.
api:
type: backend
label: API
sublabel: Node.js
brand: node-js
description: Owns authentication, orders and the catalogue.
diagram: 0300-components/api.archify
worker:
type: backend
label: Worker
sublabel: async jobs
description: Picks jobs from the queue; emails, invoices, exports.
queue:
type: messagebus
label: Queue
sublabel: SQS
description: Job queue between the API and the workers.
db:
type: database
label: Database
sublabel: PostgreSQL
brand: postgresql
description: Orders, customers and catalogue; the source of truth.
cache:
type: database
label: Cache
sublabel: Redis
brand: redis
description: Sessions and catalogue cache; nothing here is the source of truth.
@@ -0,0 +1,12 @@
schema_version: 1
elements:
customer:
type: external
label: Customer
sublabel: web and mobile
description: Buys products; signs in with email or a social account.
support:
type: external
label: Support agent
sublabel: back office
description: Handles refunds and order problems in the admin console.
@@ -0,0 +1,19 @@
schema_version: 1
elements:
shop:
type: cloud
label: Online Shop
sublabel: the system
description: Everything we build and run; web app, API, workers and stores.
diagram: 0200-containers/online-shop.archify
psp:
type: external
label: Payment provider
sublabel: Stripe
brand: stripe
description: Charges cards and pays out; we never see card numbers.
email:
type: external
label: Email service
sublabel: transactional mail
description: Sends order confirmations and password resets.
+169
View File
@@ -0,0 +1,169 @@
# Authoring Archify diagrams
What an agent needs to write a diagram that validates and reads well. The schemas under `scripts/vendor/archify/schemas/` are the authority; read the kind's schema and `common.schema.json` before using a field not listed here. Archify rejects unknown keys everywhere (`additionalProperties: false`).
## Every diagram
```yaml
schema_version: 1 # 1 for architecture, sequence, dataflow; 2 for new workflow and lifecycle
diagram_type: architecture # architecture | workflow | sequence | dataflow | lifecycle
meta:
title: Containers # required
output: containers.html # required: relative POSIX path ending in .html, no "..", no drive letters
```
Optional `meta` keys: `subtitle` (omit unless asked), `viewBox: [w, h]` (omit for architecture; Archify measures), `legend` (`mode: auto|all|hidden`, `entries: { backend: { label: Service } }` to rename), `locale` (`en`, `zh-CN`; others need `translations`), `visual_preset` (`classic` default, `signal-flow`, `blueprint`, `editorial`; only on request), `quality_profile` (`standard`, `showcase`), `animation: trace`, `repository` plus node `sources` for source-backed diagrams, `cards` (summary blocks under the diagram: `dot`, `title`, `items`).
Ids match `^[a-zA-Z][a-zA-Z0-9_-]*$` and are unique in their collection. Relationship `id`s are optional (they give stable `#relation=` links).
## Node fields (all kinds)
| Field | Values |
|---|---|
| `type` | `frontend`, `backend`, `database`, `cloud`, `security`, `messagebus`, `external` (lifecycle states: `start`, `active`, `waiting`, `decision`, `success`, `failure`, `neutral`, `external`) |
| `label` | required; keep it short, the box is small |
| `sublabel` | one fact: technology, protocol, role |
| `tag` | small badge text (not in sequence participants) |
| `icon` | corner symbol: `calendar`, `clock`, `person`, `briefcase`, `flag`, `moon`, a type name, or `none` |
| `brand` | an Archify brand id (see Brands) |
| `sources` | 1 to 3 `{ path, line, end_line, label }` repository references, with `meta.repository` |
`type` sets colour and legend entry. Pick by role: people and outside systems `external`; the own system as one box `cloud`; UIs `frontend`; services and jobs `backend`; stores and caches `database`; queues and topics `messagebus`; auth, gateways, secrets `security`.
## Architecture
Collections: `components` (required), `connections`, `boundaries`, `cards`, `layout`.
Placement is yours; Archify routes the lines but never moves boxes. Use free positions on this grid, which is what the templates and `examples/online-shop` use:
- box `size: [130, 60]` (`[150, 60]` for a system-level box with a longer label)
- columns at x = 40, 250, 460, 670, 880 (step 210: an 80px clear gap between boxes)
- rows at y = 60, 160, 260, 360, 460 (step 100: a 40px gap)
Rules of thumb that keep the validator quiet:
- Place by connections first: main path left to right, neighbours adjacent; a store or branch directly above or below its owner, centred, so the edge is one straight segment; a return edge on the side without branches.
- A horizontal label on the 80px gap fits about 9 ASCII characters (`6.5px per character + 21px`). Longer labels: widen the step (250), or set `labelAt` when the validator asks.
- Label width inside a box: `6.5px per character + 13px` must be under the box width; a 130px box takes about 18 characters. Sublabel: `5.4px per character + 8px`.
- A side with k connections needs `32 + 14 * (k - 1)` px; spread a hub's partners over two or three sides.
- External actors stay outside the boundary, including its padding.
- Omit `meta.viewBox`; Archify sizes the canvas.
Connections: `from`, `to`, `label`, `variant` (`default`, `emphasis` for the main path, `security`, `dashed`), `fromSide`/`toSide` (`left|right|top|bottom`, pin only when needed), `route` (`auto`, `straight`, `orthogonal-h`, `orthogonal-v`), `via: [[x, y], ...]` (explicit waypoints, must leave the source outward on the pinned side), `labelAt: [x, y]`, `labelDx`, `labelDy`, `labelSegment`, `width`.
Boundaries: `kind: region | security-group`, `label`, `wraps: [ids]`, `pad`. A boundary states a real ownership, trust or deployment fact; it does not replace connections.
Alternative to `pos`: `row`/`col` per component with `layout: { mode: grid, origin, cols, cellW, cellH, gapX, gapY }`. Do not mix `pos` and `row`/`col` in one diagram: Archify stops with `internal/unclassified` instead of a readable error.
## Workflow (schema_version 2)
```yaml
schema_version: 2
diagram_type: workflow
meta: { title: Change approval, output: workflow.html }
lanes:
- { id: author, label: Author }
- { id: review, label: Reviewers }
mainPath: [draft, review, approved, released]
nodes:
- { id: draft, lane: author, col: 0, type: frontend, label: Draft change }
- { id: review, lane: review, col: 1, type: security, label: Review, sublabel: two approvals }
edges:
- { id: submit, from: draft, to: review, label: submit }
- { from: review, to: draft, label: changes requested, role: branch, variant: dashed }
```
`col` is 0..5, the logical step. `mainPath` lists the happy path in order; keep it monotonic. `edges.role`: `main`, `branch`, `async`, `return`, `error`. Optional `phases` and `groups` (`fromCol`, `toCol`) and `yOffset` per node (symmetric values like -90/0/90 inside one lane). `meta.legend.entries` renames types for non-software plans (see `everyday-plan` in the extension's templates: `backend` as "Time off", icons `calendar`, `briefcase`).
## Sequence
```yaml
schema_version: 1
diagram_type: sequence
meta: { title: Login, output: sequence.html, viewBox: [960, 520] }
participants:
- { id: client, type: external, label: Client }
- { id: api, type: backend, label: API }
messages:
- { from: client, to: api, y: 160, label: POST /login, variant: emphasis }
- { from: api, to: client, y: 210, label: 200 OK, variant: return }
```
Participants are columns in order; messages run top to bottom by `y` (50px apart is comfortable). `variant` adds `return`. Optional `note` per message, `segments` (`from`, `to`, `label`) and `activations` (`participant`, `from`, `to`). `meta.column_fit: spread` uses a wide viewBox for column distance.
## Dataflow
```yaml
schema_version: 1
diagram_type: dataflow
meta: { title: Sales reporting, output: dataflow.html, viewBox: [960, 420] }
stages: [{ label: Sources }, { label: Processing }, { label: Storage }]
nodes:
- { id: shop, type: frontend, label: Web shop, stage: 0, row: 0 }
- { id: etl, type: backend, label: Nightly ETL, stage: 1, row: 0 }
flows:
- { from: shop, to: etl, label: orders }
- { from: crm, to: etl, label: customers, classification: PII, variant: security }
```
Stages are columns (index into `stages`), rows separate parallel streams. Label only data contracts, classifications and cross-boundary movement.
## Lifecycle (schema_version 2)
```yaml
schema_version: 2
diagram_type: lifecycle
meta: { title: Order status, output: lifecycle.html }
lanes:
- { id: main, label: Normal path }
- { id: exits, label: Exits }
states:
- { id: placed, type: start, label: Placed, lane: main, col: 0 }
- { id: paid, type: active, label: Paid, lane: main, col: 1 }
- { id: cancelled, type: failure, label: Cancelled, lane: exits, col: 0 }
transitions:
- { id: pay, from: placed, to: paid }
- { id: cancel, from: placed, to: cancelled, variant: security }
```
Each lane is a row (`main` first, `terminal` last); `col` 0..4 is one shared x grid, so put an exit in the column of the state it leaves. Every transition is authored, including the main path. A recoverable failure needs a real transition back. Omit `viewBox` in v2.
## Brands
`brand` puts a product logo on a node. Use the canonical id from the bundled catalogue:
```bash
node scripts/vendor/archify/bin/archify.mjs brands "kafka" --json # id apache-kafka
node scripts/vendor/archify/bin/archify.mjs brands --json # all 107
```
Ids include `postgresql`, `redis`, `mysql`, `mongodb`, `apache-kafka`, `rabbitmq`, `docker`, `kubernetes`, `terraform`, `node-js`, `react`, `vue`, `python`, `go`, `rust`, `typescript`, `spring`, `django`, `fastapi`, `github`, `gitlab`, `jenkins`, `grafana`, `prometheus`, `stripe`, `paypal`, `openai`, `anthropic`, `claude`, `google-cloud`, `cloudflare`, `vercel`, `supabase`, `snowflake`, `databricks`. An unknown brand is a validation error; omit `brand` rather than guess. A URL brand needs `archify brands capture <url>` first (network) and the returned digest.
## Validator output and fixes
`task model:validate` and `task diagram:validate` map Archify's diagnostics to the file, line and JSON pointer. Fix in this order, one coherent change at a time, validating after each:
| Code or message | Cause | Fix |
|---|---|---|
| `YAML: ...` | syntax error, usually `: ` inside a plain scalar | `>` block scalar or quotes |
| `schema/additionalProperties` | an unknown key | remove or rename it; model keys only in registries and `ref` nodes |
| `schema/...` other | wrong enum, missing `meta.output`, bad id, `schema_version` not allowed for the kind | read the message; the pointer names the node |
| `Label "..." is wider than component` | label too long for `size` | widen `size[0]` or shorten |
| `Components "a" and "b" are less than 8px apart` | overlap | use the suggested `pos` |
| `clean-flow/edge-through-node` | an edge crosses an unrelated box | move the box off the corridor, or `fromSide`/`toSide`, or `via` |
| `clean-flow/endpoint-side-direction` | `via` leaves the box inward | first waypoint must be outward on the pinned side |
| `composition/label-gap`, `label_route_clearance` | labels collide with routes, boxes or each other | enlarge the gap or set the suggested `labelAt`; never delete the label |
| `layout/boundary-out-of-bounds`, `layout/route-out-of-bounds` | negative coordinates or an authored `viewBox` too small | move inward, or remove/enlarge `viewBox` |
| `composition/excessive-route-detour` | an explicit `via` is far longer than needed | remove the `via` |
| `Component ids must be unique` | duplicate id | rename, or `id:` next to `ref:` |
| `brand/unknown` | brand id not in the catalogue; the message lists the closest ids | use one of them, or omit `brand` |
`--quality=showcase` turns composition warnings (crossings, corridors, border runs, route rhythm) into failures; use it for diagrams that will be presented. `--layout-json` on the plain Archify CLI (`archify validate architecture x.json --layout-json`) prints the resolved boxes, routes and label positions when a repair needs measurements; get `x.json` from `task model:resolve`.
## Composition
- One obvious main path, `variant: emphasis` on it. Branches beside their owner. Stores below their writer.
- Labels carry meaning (protocol, action, direction, async). Omit one only when both endpoints imply it.
- Group only real boundaries. A boundary around one box needs a real isolation fact.
- A C4 level answers one question; put details in the next level and link it with `diagram:`.
- Title only; no subtitle, no preset, no legend overrides unless asked.
+99
View File
@@ -0,0 +1,99 @@
# Folder layouts
Diagrams are `.archify` files; a registry is an `.archify-registry` file. The extension knows a file by its extension; the names in front are yours, with one reserved name: `main.archify`, the entry point of a model.
## A single diagram
```
anywhere/
payment-flow.archify
```
Nothing else. Works as a plain diagram, also when it sits inside a model folder.
## A model, smallest form
```
online-shop/
main.archify entry point: id, name, description, optionally a diagram
online-shop.archify-registry one name for one thing: what several diagrams use
containers.archify
checkout-flow.archify
order-lifecycle.archify
```
The folder that contains `main.archify` is the root; everything below belongs to the model. Any number of diagrams of any kind. A diagram that uses a thing from the registry refers to it by name; a diagram that does not is still a plain diagram.
## A larger model, grouped
```
online-shop/
main.archify
registry/
people.archify-registry
services.archify-registry
stores.archify-registry
services/
containers.archify
api-components.archify
processes/
checkout-flow.archify
refund-sequence.archify
order-lifecycle.archify
```
Groups are folders you choose, shown in folder order. The registry may be one file or a folder of files split any way (by kind, by team).
## C4
The four C4 levels are four groups of diagrams. The registry holds the things C4 talks about: people, software systems, containers, components. Each level's diagram zooms into one thing from the level above, and the registry entry of that thing points at its diagram.
```
online-shop/
main.archify id, name, description; may hold an overview diagram
registry/
people.archify-registry customer, support agent
systems.archify-registry online shop, payment provider, email service
containers.archify-registry web app, api, worker, database, queue
components.archify-registry api: auth, orders, catalogue; worker: mailer, invoicing
0100-context/
online-shop.archify people and systems around the shop
0200-containers/
online-shop.archify inside the shop: web app, api, worker, database, queue
0300-components/
api.archify inside the api: auth, orders, catalogue
worker.archify inside the worker: mailer, invoicing
0400-code/ optional; usually left out
orders.archify classes inside the orders component
```
How the levels hang together:
- The system "online shop" in `systems.archify-registry` points at `0200-containers/online-shop.archify`. Click the shop in the context diagram and the containers diagram opens.
- The container "api" in `containers.archify-registry` points at `0300-components/api.archify`. Click the api, see its components.
- The same api, drawn in the containers diagram and in several component diagrams, is one entry with one description.
The numbered folders are only for sorting; nothing depends on the names. A team that wants only context and containers has `main.archify`, two folders and two registry files. `task model:scaffold --c4` creates this shape with three levels and empty registries; `examples/online-shop/` in this skill is the filled-in version.
Component types that fit C4: people are `external`; the system itself and other systems are `cloud` (own) or `external` (others); containers are `frontend`, `backend`, `database`, `messagebus`, `security`; components inside a container use the same set, wrapped in a `region` boundary named after the container.
## How the extension knows it is in a model
The nearest folder above a file that contains `main.archify` is the root. Under that root, at any depth: `*.archify` are member diagrams, `*.archify-registry` make up the registry, subfolders are groups. `node_modules` and `.git` are skipped.
- No `main.archify` anywhere above: a single diagram.
- A diagram inside a model still opens, renders and exports on its own.
- A second `main.archify` deeper down would start a nested model; not supported, reported.
## What you see in VS Code
Any `.archify` diagram: click it, the diagram opens, with Show Source, live updates, Problems, export and 3D.
`main.archify`: the model page instead of a diagram:
1. the metadata: id, name, description;
2. the file's own diagram, if it has one, drawn like any other diagram;
3. the list of diagrams, grouped by folder, each with its title and kind; diagrams next to `main.archify` first, ungrouped, then deeper ones by folder path;
4. the registry: every shared thing with its description and "used in N diagrams".
`task model:list` prints the same information.
+102
View File
@@ -0,0 +1,102 @@
# File formats of a model
Three file kinds, all YAML, all read by the extension. Archify itself only ever sees plain diagrams; the model keys are resolved first (by the extension, and by this skill's tasks in the same way).
## `main.archify`
The entry point of a model. The folder that contains it is the model root.
```yaml
schema_version: 1
model:
id: online-shop # ^[a-zA-Z][a-zA-Z0-9_-]*$
name: Online Shop
description: >
Web shop, its API, workers and stores.
```
Optionally, below the `model` block, a diagram exactly as in any other `.archify` file (`diagram_type`, `meta`, `components`, ...). The extension shows it on the model page as the overview. Archify never sees the `model` block.
A `main.archify` without a diagram is valid (metadata only). A `model` block in any other file is ignored and reported as a warning.
## `name.archify-registry`
Shared things of the model. One file or many, anywhere under the root, split any way (by kind, by team).
```yaml
schema_version: 1
elements:
api:
type: backend
label: API
sublabel: REST service
brand: node-js
description: >
Node.js service behind the load balancer.
diagram: 0300-components/api.archify
db:
type: database
label: Database
sublabel: PostgreSQL
brand: postgresql
description: Orders, customers and catalogue.
```
- The key is the element id, unique across all registry files of the model. A duplicate is an error.
- `type`, `label`, `sublabel`, `tag`, `icon`, `brand`, `sources` are Archify's component fields and are copied into every diagram that refers to the element. `type` and `label` are required.
- `description` is the one description of the thing; shown on hover and on the model page, never sent to Archify.
- `diagram` is the link: the diagram that explains the element, relative to the model root, ending in `.archify`, must exist. Clicking the element in any diagram opens it.
- Any other key is reported as a warning and never reaches a diagram.
## A diagram that refers to the registry
```yaml
components:
- ref: api
pos: [250, 160]
size: [130, 60]
- ref: db
label: Orders DB # a diagram may override label, sublabel, tag
pos: [460, 260]
size: [130, 60]
- id: cron # an unshared element, inline, as today
type: backend
label: Nightly job
pos: [460, 420]
size: [130, 60]
```
- `ref` names a registry element; `id` defaults to the same name. `id: db-replica` with `ref: db` draws the same element twice or under another id.
- A diagram may override `label`, `sublabel` and `tag`; never `type`, `brand`, `icon` or `sources` (if those differ, it is a different element). The validator reports the attempt.
- Before Archify runs, each `ref` component is replaced by the registry element plus the diagram's own fields; `description` and `diagram` are dropped; what remains is an ordinary component.
- Connections, boundaries and everything else stay as they are and use the ids as before.
- The same works in the node collection of every kind: `components` (architecture), `nodes` (workflow, dataflow), `participants` (sequence), `states` (lifecycle). Only fields the kind's schema accepts are copied (sequence participants have no `tag`; lifecycle states need a lifecycle `type`).
## Links between diagrams
A diagram may point at another diagram from a component, whether shared or inline:
```yaml
- ref: api
pos: [250, 160]
size: [130, 60]
diagram: 0300-components/api.archify # overrides the registry's link for this diagram
```
Registry `diagram` links and component `diagram` links are the same mechanism; the component's wins when both exist. Paths are relative to the model root. Levels, zoom-in and "see also" are this one mechanism.
## Outside a model
A diagram with `ref` components outside any model (no `main.archify` above it) has no registry. The extension reports "no registry found" and shows the component by its ref name with type `external`, so the picture still renders. The validator treats it as an error: define the element inline or create the model. A `diagram:` link outside a model is dropped with a warning.
## What the resolver does, exactly
For each diagram file under the root (and a single file outside a model):
1. Parse the YAML; a syntax error is reported with its line and stops that file.
2. Remove `model` (main.archify only).
3. For each node in the kind's collection: expand `ref`, apply the diagram's own keys, drop `ref` and `diagram`, keep `id` (own `id` or the ref name).
4. Check ids are unique in the diagram, links exist, overrides are allowed.
5. Hand the result to `archify validate <kind> <json> --json` (or `render`).
`task model:resolve -- --file=… --out=x.json` writes step 4's result.
+19
View File
@@ -0,0 +1,19 @@
# Node / pnpm
node_modules/
dist/
.pnpm-store/
# Build artefacts
*.js.map
*.d.ts.map
# Environment
.env
.env.local
.env.*.local
# Test output
.test-output/
# OS
.DS_Store
+18
View File
@@ -0,0 +1,18 @@
# https://taskfile.dev
version: "3"
tasks:
validate:
desc: 'Validate one diagram -- --file="<diagram.archify>" [--quality=standard|showcase]'
cmds:
- |
./.scripts/diagram/api/diagram--validate.sh {{ .CLI_ARGS }}
silent: true
render:
desc: 'Render one diagram to HTML -- --file="<diagram.archify>" --out="<file.html>" [--quality=standard|showcase]'
cmds:
- |
./.scripts/diagram/api/diagram--render.sh {{ .CLI_ARGS }}
silent: true
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
. ./.scripts/diagram/lib/--index.sh
_diagram__diagram__render "$@"
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
. ./.scripts/diagram/lib/--index.sh
_diagram__diagram__validate "$@"
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
# CLI-only module: all input comes from flags. No environment variables are read.
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
# CLI-only module: nothing to validate.
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
. ./.scripts/diagram/lib/-node--run.sh
. ./.scripts/diagram/lib/-diagram--validate.sh
. ./.scripts/diagram/lib/-diagram--render.sh
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
. ./.scripts/loggers/lib/--index.sh
#
# --> passed parameters are read & exported environment variables
#
. ./.scripts/diagram/lib/--env-vars-reader.sh
#
# --> required environment variables are validated for existence
#
. ./.scripts/diagram/lib/--env-vars-validator.sh
#
# --> available functions are imported/exported
#
. ./.scripts/diagram/lib/--index-api.sh
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
# Runs the "diagram-render" action of the Node CLI. Flags are forwarded unchanged.
_diagram__diagram__render() {
_diagram__node__run --action diagram-render "$@"
}
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
# Runs the "diagram-validate" action of the Node CLI. Flags are forwarded unchanged.
_diagram__diagram__validate() {
_diagram__node__run --action diagram-validate "$@"
}
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
# Runs the Node action dispatcher (YAML to stdout, exit code = success).
_diagram__node__run() {
node ./src/cli/commands.mjs "$@"
}
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
export LOGGER_IS_ENABLED_INFO="${LOGGER_IS_ENABLED_INFO:-true}"
export LOGGER_IS_ENABLED_ERROR="${LOGGER_IS_ENABLED_ERROR:-true}"
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
. ./.scripts/loggers/lib/-info.sh
. ./.scripts/loggers/lib/-error.sh
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
. ./.scripts/loggers/lib/--env-vars-reader.sh
. ./.scripts/loggers/lib/--index-api.sh
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
_loggers_error() {
if [ "${LOGGER_IS_ENABLED_ERROR}" = true ]; then
printf '# ERROR # %s\n' "$*" >&2
fi
}
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
# Logs go to stderr; stdout is reserved for the YAML result.
_loggers_info() {
if [ "${LOGGER_IS_ENABLED_INFO}" = true ]; then
printf '# INFO # %s\n' "$*" >&2
fi
}
+32
View File
@@ -0,0 +1,32 @@
# https://taskfile.dev
version: "3"
tasks:
validate:
desc: 'Validate a whole model -- --dir="<model folder or any file inside>" [--quality=standard|showcase]'
cmds:
- |
./.scripts/model/api/model--validate.sh {{ .CLI_ARGS }}
silent: true
list:
desc: 'What the model page shows -- --dir="<model folder or any file inside>"'
cmds:
- |
./.scripts/model/api/model--list.sh {{ .CLI_ARGS }}
silent: true
scaffold:
desc: 'Create a model -- --dir="<folder>" --id="<id>" --name="<name>" [--description="<text>"] [--c4]'
cmds:
- |
./.scripts/model/api/model--scaffold.sh {{ .CLI_ARGS }}
silent: true
resolve:
desc: 'Resolved Archify JSON of one diagram -- --file="<diagram.archify>" [--out="<file.json>"]'
cmds:
- |
./.scripts/model/api/model--resolve.sh {{ .CLI_ARGS }}
silent: true
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
. ./.scripts/model/lib/--index.sh
_model__model__list "$@"
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
. ./.scripts/model/lib/--index.sh
_model__model__resolve "$@"
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
. ./.scripts/model/lib/--index.sh
_model__model__scaffold "$@"
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
. ./.scripts/model/lib/--index.sh
_model__model__validate "$@"
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
# CLI-only module: all input comes from flags. No environment variables are read.
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
# CLI-only module: nothing to validate.
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
. ./.scripts/model/lib/-node--run.sh
. ./.scripts/model/lib/-model--validate.sh
. ./.scripts/model/lib/-model--list.sh
. ./.scripts/model/lib/-model--scaffold.sh
. ./.scripts/model/lib/-model--resolve.sh
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
. ./.scripts/loggers/lib/--index.sh
#
# --> passed parameters are read & exported environment variables
#
. ./.scripts/model/lib/--env-vars-reader.sh
#
# --> required environment variables are validated for existence
#
. ./.scripts/model/lib/--env-vars-validator.sh
#
# --> available functions are imported/exported
#
. ./.scripts/model/lib/--index-api.sh
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
# Runs the "model-list" action of the Node CLI. Flags are forwarded unchanged.
_model__model__list() {
_model__node__run --action model-list "$@"
}
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
# Runs the "model-resolve" action of the Node CLI. Flags are forwarded unchanged.
_model__model__resolve() {
_model__node__run --action model-resolve "$@"
}
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
# Runs the "model-scaffold" action of the Node CLI. Flags are forwarded unchanged.
_model__model__scaffold() {
_model__node__run --action model-scaffold "$@"
}
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
# Runs the "model-validate" action of the Node CLI. Flags are forwarded unchanged.
_model__model__validate() {
_model__node__run --action model-validate "$@"
}
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
# Runs the Node action dispatcher (YAML to stdout, exit code = success).
_model__node__run() {
node ./src/cli/commands.mjs "$@"
}
+11
View File
@@ -0,0 +1,11 @@
# https://taskfile.dev
version: "3"
tasks:
sync:
desc: 'Refresh vendor/archify and vendor/yaml from local checkouts -- [<archify-checkout>] [--yaml-src <path>]'
cmds:
- |
./.scripts/runtime/api/archify--sync.sh {{ .CLI_ARGS }}
silent: true
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
. ./.scripts/runtime/lib/--index.sh
_runtime__archify__sync "$@"
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
# CLI-only module: all input comes from flags. No environment variables are read.
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
# CLI-only module: nothing to validate.
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
. ./.scripts/runtime/lib/-archify--sync.sh
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
. ./.scripts/loggers/lib/--index.sh
. ./.scripts/runtime/lib/--env-vars-reader.sh
. ./.scripts/runtime/lib/--env-vars-validator.sh
. ./.scripts/runtime/lib/--index-api.sh
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
# Refreshes vendor/archify and vendor/yaml from local checkouts.
# Flags: [<archify-checkout>] [--yaml-src <node_modules/yaml>]; see src/tools/sync-archify.mjs.
_runtime__archify__sync() {
_loggers_info "Syncing the Archify runtime into vendor/"
node ./src/tools/sync-archify.mjs "$@"
}
+11
View File
@@ -0,0 +1,11 @@
# https://taskfile.dev
version: "3"
tasks:
run:
desc: "Run the skill's own checks against examples/online-shop and a scaffolded model"
cmds:
- |
./.scripts/suite/api/tests--run.sh {{ .CLI_ARGS }}
silent: true
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
. ./.scripts/suite/lib/--index.sh
_suite__tests__run "$@"
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
# CLI-only module: all input comes from flags. No environment variables are read.
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
# CLI-only module: nothing to validate.
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
. ./.scripts/suite/lib/-tests--run.sh
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
. ./.scripts/loggers/lib/--index.sh
. ./.scripts/suite/lib/--env-vars-reader.sh
. ./.scripts/suite/lib/--env-vars-validator.sh
. ./.scripts/suite/lib/--index-api.sh
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
# Runs every task against examples/online-shop and a scaffolded temp model.
_suite__tests__run() {
node ./src/test/run.mjs "$@"
}
+31
View File
@@ -0,0 +1,31 @@
# https://taskfile.dev
version: "3"
includes:
model: ./.scripts/model/Taskfile.yml
diagram: ./.scripts/diagram/Taskfile.yml
runtime: ./.scripts/runtime/Taskfile.yml
suite: ./.scripts/suite/Taskfile.yml
tasks:
default:
cmds:
- task --list-all
silent: true
test:
desc: "Run the skill's own checks (same as suite:run)"
cmds:
- task: suite:run
vars:
CLI_ARGS: "{{ .CLI_ARGS }}"
silent: true
sync:
desc: "Refresh the vendored Archify runtime (same as runtime:sync)"
cmds:
- task: runtime:sync
vars:
CLI_ARGS: "{{ .CLI_ARGS }}"
silent: true
+7
View File
@@ -0,0 +1,7 @@
{
"name": "archify-vscode-ext-skill-tools",
"private": true,
"type": "module",
"description": "Model and diagram tools for the archify-vscode-ext skill. No dependencies; Node 18+. Archify (MIT) and yaml (ISC) are vendored under vendor/.",
"engines": { "node": ">=18" }
}
@@ -0,0 +1,45 @@
// Renders one diagram to a standalone Archify HTML page, resolving refs when
// the file sits inside a model.
import fs from 'node:fs';
import path from 'node:path';
import { archifyVersion, renderJson, withTempJson } from '../../services/archify.mjs';
import { DIAGRAM_EXT, presentProblem, summarize } from '../../services/model.mjs';
import { resolveOne } from '../model-resolve/action.mjs';
export const help = `diagram-render — render one diagram to HTML (refs resolved when inside a model)
--file <path> the .archify file (required)
--out <path> the .html file to write (required)
--quality <name> Archify quality profile: standard (default) or showcase
--repo-root <path> Git checkout for diagrams with meta.repository and sources`;
export async function run(input) {
if (!input.file) return { success: false, error: '--file is required: the .archify file to render.', help };
if (!input.out) return { success: false, error: '--out is required: where to write the HTML.', help };
const file = path.resolve(String(input.file));
const out = path.resolve(String(input.out));
if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) return { success: false, error: `${file} is not a file.` };
const { root, registry, entry } = resolveOne(file);
const problems = [...(registry?.problems ?? []), ...entry.problems];
let summary = summarize(problems);
if (summary.errors > 0 || !entry.resolved) {
return { success: false, root: root ?? null, file: entry.file, error: entry.resolved ? 'Resolve problems first.' : 'The file has no diagram to render.', summary, problems: problems.map(presentProblem) };
}
fs.mkdirSync(path.dirname(out), { recursive: true });
const name = path.basename(file, DIAGRAM_EXT) || 'diagram';
const render = withTempJson(entry.resolved, `${name}.${entry.diagramType}`, (json) => renderJson(entry.diagramType, json, out, { quality: input.quality || undefined, repoRoot: input['repo-root'] || undefined }));
if (!render.ok) problems.push({ severity: 'error', file: entry.file, message: render.error });
summary = summarize(problems);
return {
success: render.ok,
root: root ?? null,
file: entry.file,
kind: entry.diagramType,
title: entry.resolved.meta?.title,
archify: archifyVersion(),
out: render.ok ? out : undefined,
bytes: render.ok && fs.existsSync(out) ? fs.statSync(out).size : undefined,
summary,
problems: problems.map(presentProblem),
};
}
@@ -0,0 +1,34 @@
// Validates one diagram through the bundled Archify, resolving refs when the
// file sits inside a model.
import fs from 'node:fs';
import path from 'node:path';
import { archifyVersion } from '../../services/archify.mjs';
import { presentProblem, summarize, validateResolved } from '../../services/model.mjs';
import { resolveOne } from '../model-resolve/action.mjs';
export const help = `diagram-validate — validate one diagram (refs resolved when inside a model)
--file <path> the .archify file (required)
--quality <name> Archify quality profile: standard (default) or showcase
--repo-root <path> Git checkout for diagrams with meta.repository and sources`;
export async function run(input) {
if (!input.file) return { success: false, error: '--file is required: the .archify file to validate.', help };
const file = path.resolve(String(input.file));
if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) return { success: false, error: `${file} is not a file.` };
const { root, registry, entry } = resolveOne(file);
const receipt = validateResolved(entry, { quality: input.quality || undefined, repoRoot: input['repo-root'] || undefined });
const problems = [...(registry?.problems ?? []), ...entry.problems];
const summary = summarize(problems);
return {
success: summary.errors === 0 && (entry.hasDiagram || entry.isMain),
root: root ?? null,
file: entry.file,
kind: entry.diagramType,
title: entry.resolved?.meta?.title,
archify: archifyVersion(),
archifyOk: receipt?.ok,
summary,
problems: problems.map(presentProblem),
};
}
+63
View File
@@ -0,0 +1,63 @@
// What the extension's model page shows: metadata, diagrams grouped by
// folder, and the registry with "used in N diagrams".
import fs from 'node:fs';
import path from 'node:path';
import { MAIN, findRoot, loadModel, presentProblem, relative, summarize } from '../../services/model.mjs';
export const help = `model-list — metadata, diagrams by folder and registry elements of a model
--dir <path> model folder, or any file/folder inside it (required)`;
export async function run(input) {
if (!input.dir) return { success: false, error: '--dir is required: the model folder or any file inside it.', help };
const target = path.resolve(String(input.dir));
if (!fs.existsSync(target)) return { success: false, error: `${target} does not exist.` };
const root = findRoot(target);
if (!root) return { success: false, error: `No ${MAIN} in ${target} or any folder above it.` };
const model = loadModel(root);
const groups = new Map();
for (const entry of model.diagrams) {
const folder = relative(root, path.dirname(entry.path));
if (!groups.has(folder)) groups.set(folder, []);
groups.get(folder).push({
file: entry.file,
title: entry.resolved?.meta?.title ?? (entry.isMain ? `${entry.model?.name ?? MAIN} (model, no diagram)` : undefined),
kind: entry.diagramType ?? (entry.isMain ? 'model' : 'unknown'),
refs: [...new Set(entry.refs)],
links: linksOf(entry),
});
}
const rootDiagrams = groups.get('') ?? [];
groups.delete('');
const elements = Object.values(model.registry.elements).map((element) => ({
id: element.id,
type: element.type,
label: element.label,
sublabel: element.sublabel,
brand: element.brand,
description: element.description,
diagram: element.diagram,
file: element.file,
usedIn: model.usage[element.id] ?? 0,
}));
const all = [...model.problems, ...model.diagrams.flatMap((entry) => entry.problems)];
return {
success: summarize(all).errors === 0,
root,
model: model.main?.model ? { id: model.main.model.id, name: model.main.model.name, description: model.main.model.description, hasDiagram: model.main.hasDiagram } : undefined,
summary: { diagrams: model.diagrams.length, groups: groups.size, registryFiles: model.registry.files.length, elements: elements.length, ...summarize(all) },
diagrams: rootDiagrams,
groups: [...groups.entries()].map(([folder, diagrams]) => ({ folder, diagrams })),
registry: elements,
problems: all.map(presentProblem),
};
}
function linksOf(entry) {
const data = entry.source.data;
if (!data || !entry.diagramType) return [];
const collection = Object.values(data).find((value) => Array.isArray(value) && value.some((item) => item && typeof item === 'object' && item.diagram));
return (collection ?? []).filter((item) => item && typeof item === 'object' && item.diagram).map((item) => ({ from: item.id ?? item.ref, to: item.diagram }));
}
@@ -0,0 +1,50 @@
// Prints or writes the resolved Archify JSON of one diagram: what the
// extension hands to Archify after refs are expanded and model-only keys
// are removed. Use the result with the plain Archify CLI or in CI.
import fs from 'node:fs';
import path from 'node:path';
import { MAIN, findRoot, loadRegistry, presentProblem, resolveDiagram, scanModel, summarize } from '../../services/model.mjs';
export const help = `model-resolve — resolved Archify JSON for one diagram
--file <path> the .archify file (required)
--out <path> write the JSON there instead of including it in the output`;
export function resolveOne(file) {
const root = findRoot(path.dirname(file));
const registry = root ? loadRegistry(root, scanModel(root).registries) : undefined;
const entry = resolveDiagram(file, root ? { root, elements: registry.elements } : {});
return { root, registry, entry };
}
export async function run(input) {
if (!input.file) return { success: false, error: '--file is required: the .archify file to resolve.', help };
const file = path.resolve(String(input.file));
if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) return { success: false, error: `${file} is not a file.` };
const { root, registry, entry } = resolveOne(file);
const problems = [...(registry?.problems ?? []), ...entry.problems];
const summary = summarize(problems);
const result = {
success: summary.errors === 0 && Boolean(entry.resolved),
root: root ?? null,
note: root ? undefined : `No ${MAIN} above the file: nothing to resolve, refs stay unresolved.`,
file: entry.file,
kind: entry.diagramType,
summary,
problems: problems.map(presentProblem),
};
if (!entry.resolved) {
if (!entry.hasDiagram && entry.isMain) result.error = `${MAIN} has no diagram part (no diagram_type).`;
return result;
}
if (input.out) {
const out = path.resolve(String(input.out));
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, `${JSON.stringify(entry.resolved, null, 2)}\n`);
result.out = out;
result.archifyCommand = `node vendor/archify/bin/archify.mjs validate ${entry.diagramType} "${out}" --json`;
} else {
result.resolved = entry.resolved;
}
return result;
}
@@ -0,0 +1,211 @@
// Creates a model: main.archify with the model block, and with --c4 an
// overview diagram, empty registries and the C4 folders with starter
// diagrams that validate. Never overwrites existing files.
import fs from 'node:fs';
import path from 'node:path';
import { ID, MAIN } from '../../services/model.mjs';
export const help = `model-scaffold — create main.archify (and with --c4 the C4 folders)
--dir <path> folder of the model; created when missing (required)
--id <id> model id, letters/digits/-/_ (required)
--name <text> model name (required)
--description <text> model description (optional)
--c4 also create registry/, 0100-context, 0200-containers, 0300-components`;
function block(text) {
// YAML folded scalar: safe for colons and long sentences.
return `>\n${String(text).trim().split('\n').map((line) => ` ${line}`).join('\n')}`;
}
function mainFile(id, name, description, withDiagram) {
const head = `schema_version: 1\nmodel:\n id: ${id}\n name: ${JSON.stringify(name)}\n description: ${block(description)}\n`;
if (!withDiagram) return head;
return `${head}# The model's own diagram: the overview shown on the model page.
# Move the system and its users into registry/ and replace "id:" by "ref:" once they are shared.
diagram_type: architecture
meta:
title: ${JSON.stringify(`${name}: overview`)}
output: main.html
components:
- id: user
type: external
label: User
pos: [40, 160]
size: [130, 60]
- id: system
type: cloud
label: ${JSON.stringify(name)}
sublabel: the system
pos: [250, 160]
size: [150, 60]
diagram: 0200-containers/${id}.archify
connections:
- { from: user, to: system, label: uses, variant: emphasis }
`;
}
function registryFile(what, example) {
return `# ${what}. One name for one thing, defined once; diagrams refer to it with "ref: <id>".
schema_version: 1
elements: {}
# Example:
${example.split('\n').map((line) => `# ${line}`).join('\n')}
`;
}
const REGISTRIES = {
'people.archify-registry': registryFile('People and roles that use the system', 'customer:\n type: external\n label: Customer\n sublabel: web and mobile\n description: Buys products.'),
'systems.archify-registry': registryFile('Software systems: the one we build and the ones around it', 'shop:\n type: cloud\n label: Online Shop\n description: Everything we build and run.\n diagram: 0200-containers/online-shop.archify'),
'containers.archify-registry': registryFile('Containers: deployable units inside the system', 'api:\n type: backend\n label: API\n sublabel: Node.js\n brand: node-js\n description: Owns orders and the catalogue.\n diagram: 0300-components/api.archify'),
};
function contextDiagram(id, name) {
return `# C4 level 1: the system and the people and systems around it.
schema_version: 1
diagram_type: architecture
meta:
title: System context
output: context.html
components:
- id: user
type: external
label: User
pos: [40, 160]
size: [130, 60]
- id: system
type: cloud
label: ${JSON.stringify(name)}
sublabel: the system
pos: [250, 160]
size: [150, 60]
diagram: 0200-containers/${id}.archify
- id: external
type: external
label: External service
pos: [480, 160]
size: [150, 60]
connections:
- { from: user, to: system, label: uses, variant: emphasis }
- { from: system, to: external, label: calls }
`;
}
function containersDiagram(name) {
return `# C4 level 2: the containers inside the system.
schema_version: 1
diagram_type: architecture
meta:
title: Containers
output: containers.html
components:
- id: user
type: external
label: User
pos: [40, 160]
size: [130, 60]
- id: web
type: frontend
label: Web App
pos: [250, 160]
size: [130, 60]
- id: api
type: backend
label: API
pos: [460, 160]
size: [130, 60]
diagram: 0300-components/api.archify
- id: db
type: database
label: Database
pos: [670, 160]
size: [130, 60]
boundaries:
- kind: region
label: ${JSON.stringify(name)}
wraps: [web, api, db]
connections:
- { from: user, to: web, label: HTTPS, variant: emphasis }
- { from: web, to: api, label: JSON }
- { from: api, to: db, label: SQL }
`;
}
const COMPONENTS_DIAGRAM = `# C4 level 3: the components inside one container.
schema_version: 1
diagram_type: architecture
meta:
title: API components
output: api.html
components:
- id: web
type: frontend
label: Web App
pos: [40, 160]
size: [130, 60]
- id: auth
type: security
label: Auth
pos: [250, 60]
size: [130, 60]
- id: orders
type: backend
label: Orders
pos: [250, 260]
size: [130, 60]
- id: db
type: database
label: Database
pos: [460, 160]
size: [130, 60]
boundaries:
- kind: region
label: API
wraps: [auth, orders]
connections:
- { from: web, to: auth, label: sign in, variant: security }
- { from: web, to: orders, label: place order }
- { from: orders, to: db, label: SQL }
`;
export async function run(input) {
const missing = ['dir', 'id', 'name'].filter((key) => !input[key] || input[key] === true);
if (missing.length) return { success: false, error: `Missing ${missing.map((key) => `--${key}`).join(', ')}.`, help };
const id = String(input.id);
if (!ID.test(id)) return { success: false, error: `--id "${id}" must match ${ID} (letters, digits, - and _; starts with a letter).` };
const name = String(input.name);
const description = input.description && input.description !== true ? String(input.description) : `${name}: diagrams of the system.`;
const dir = path.resolve(String(input.dir));
const c4 = Boolean(input.c4);
const files = { [MAIN]: mainFile(id, name, description, c4) };
if (c4) {
for (const [file, text] of Object.entries(REGISTRIES)) files[`registry/${file}`] = text;
files[`0100-context/${id}.archify`] = contextDiagram(id, name);
files[`0200-containers/${id}.archify`] = containersDiagram(name);
files['0300-components/api.archify'] = COMPONENTS_DIAGRAM;
}
const existing = Object.keys(files).filter((file) => fs.existsSync(path.join(dir, file)));
if (existing.length) return { success: false, error: `Refusing to overwrite existing files in ${dir}.`, existing };
if (fs.existsSync(dir) && !fs.statSync(dir).isDirectory()) return { success: false, error: `${dir} is not a folder.` };
const created = [];
for (const [file, text] of Object.entries(files)) {
const full = path.join(dir, file);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, text);
created.push(file);
}
return {
success: true,
root: dir,
model: { id, name },
created,
nextSteps: [
`task model:validate -- --dir="${dir}"`,
c4 ? 'Move shared things (people, systems, containers) into registry/*.archify-registry and replace "id:" by "ref:" in the diagrams.' : 'Add diagrams (*.archify) and registries (*.archify-registry) below the folder.',
`Open ${path.join(dir, MAIN)} in VS Code with the Archify Diagram Viewer extension to see the model page.`,
],
};
}
@@ -0,0 +1,67 @@
// Validates a whole model: registry, every diagram (refs resolved, Archify
// validator run), and every diagram: link. Also works on a single diagram
// outside a model (Archify validation only).
import fs from 'node:fs';
import path from 'node:path';
import { archifyVersion } from '../../services/archify.mjs';
import { MAIN, findRoot, loadModel, presentProblem, resolveDiagram, summarize, validateResolved } from '../../services/model.mjs';
export const help = `model-validate — validate a model (or a single diagram outside a model)
--dir <path> model folder, or any file/folder inside it (required)
--quality <name> Archify quality profile: standard (default) or showcase
--repo-root <path> Git checkout for diagrams with meta.repository and sources`;
function options(input) {
return { quality: input.quality || undefined, repoRoot: input['repo-root'] || undefined };
}
export function diagramReport(entry) {
return {
file: entry.file,
kind: entry.diagramType ?? (entry.isMain ? 'model' : undefined),
title: entry.resolved?.meta?.title ?? (entry.isMain ? entry.model?.name : undefined),
ok: summarize(entry.problems).errors === 0,
problems: entry.problems.map(presentProblem),
};
}
export async function run(input) {
if (!input.dir) return { success: false, error: '--dir is required: the model folder or any file inside it.', help };
const target = path.resolve(String(input.dir));
if (!fs.existsSync(target)) return { success: false, error: `${target} does not exist.` };
const root = findRoot(target);
if (!root) {
if (fs.statSync(target).isDirectory()) {
return { success: false, error: `No ${MAIN} in ${target} or any folder above it. Scaffold one with model-scaffold, or pass a single .archify file.` };
}
const entry = resolveDiagram(target);
validateResolved(entry, options(input));
const report = diagramReport(entry);
return {
success: report.ok,
mode: 'single-diagram',
note: `No ${MAIN} above ${target}: Archify validation only, refs cannot be resolved.`,
archify: archifyVersion(),
summary: summarize(entry.problems),
diagrams: [report],
};
}
const model = loadModel(root, options(input));
for (const entry of model.diagrams) validateResolved(entry, options(input));
const diagrams = model.diagrams.map(diagramReport);
const all = [...model.problems, ...model.diagrams.flatMap((entry) => entry.problems)];
const summary = summarize(all);
return {
success: summary.errors === 0,
mode: 'model',
root,
model: model.main?.model ? { id: model.main.model.id, name: model.main.model.name } : undefined,
archify: archifyVersion(),
summary: { ...summary, diagrams: diagrams.length, diagramsOk: diagrams.filter((item) => item.ok).length, registryFiles: model.registry.files.length, elements: Object.keys(model.registry.elements).length },
problems: model.problems.map(presentProblem),
diagrams,
};
}
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env node
// Action dispatcher: node src/cli/commands.mjs --action <name> [--flag value]...
// Prints YAML to stdout; exit code 0 when the result has success: true.
import * as modelValidate from '../actions/model-validate/action.mjs';
import * as modelList from '../actions/model-list/action.mjs';
import * as modelScaffold from '../actions/model-scaffold/action.mjs';
import * as modelResolve from '../actions/model-resolve/action.mjs';
import * as diagramValidate from '../actions/diagram-validate/action.mjs';
import * as diagramRender from '../actions/diagram-render/action.mjs';
import { toYaml } from '../services/yaml.mjs';
const ACTIONS = {
'model-validate': modelValidate,
'model-list': modelList,
'model-scaffold': modelScaffold,
'model-resolve': modelResolve,
'diagram-validate': diagramValidate,
'diagram-render': diagramRender,
};
function parseArgs(argv) {
const options = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith('--')) continue;
const eq = arg.indexOf('=');
if (eq > 0) options[arg.slice(2, eq)] = arg.slice(eq + 1);
else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) options[arg.slice(2)] = argv[++i];
else options[arg.slice(2)] = true;
}
return options;
}
const options = parseArgs(process.argv.slice(2));
const action = ACTIONS[options.action];
if (!action || options.help) {
const list = Object.values(ACTIONS).map((module) => module.help).join('\n\n');
const unknown = options.action && !action ? `Unknown action ${options.action}\n\n` : '';
process.stdout.write(`${unknown}Usage: node src/cli/commands.mjs --action <name> [options]\n\nActions:\n\n${list}\n\nExit code: 0 when success: true, 1 otherwise, 2 for an unknown action.\n`);
process.exit(action || !options.action ? 0 : 2);
}
try {
const result = await action.run(options);
process.stdout.write(toYaml(result));
process.exit(result.success ? 0 : 1);
} catch (error) {
process.stdout.write(toYaml({ success: false, error: error.message }));
process.exit(1);
}
+123
View File
@@ -0,0 +1,123 @@
// Runs the bundled Archify CLI (vendor/archify) on a resolved JSON file.
// Node itself (process.execPath) runs the CLI, so no global install is needed.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const scripts = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
export const ARCHIFY_BIN = path.join(scripts, 'vendor', 'archify', 'bin', 'archify.mjs');
export const ARCHIFY_INFO = path.join(scripts, 'vendor', 'archify.json');
export function archifyVersion() {
try {
return JSON.parse(fs.readFileSync(ARCHIFY_INFO, 'utf8')).version;
} catch {
return 'unknown';
}
}
function run(args) {
const result = spawnSync(process.execPath, [ARCHIFY_BIN, ...args], {
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
env: { ...process.env, ARCHIFY_SKIP_UPDATE_CHECK: '1', NO_COLOR: '1' },
});
return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '', error: result.error };
}
// Writes the resolved diagram to a temp file, runs the callback with its
// path, then removes the temp folder.
export function withTempJson(diagram, name, callback) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-skill-'));
try {
const file = path.join(dir, `${name}.json`);
fs.writeFileSync(file, `${JSON.stringify(diagram, null, 2)}\n`);
return callback(file);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
// archify validate <type> <input.json> --json → the receipt object.
export function validateJson(type, file, options = {}) {
const args = ['validate', type, file, '--json'];
if (options.quality) args.push('--quality', options.quality);
if (options.repoRoot) args.push('--repo-root', options.repoRoot);
const result = run(args);
if (result.error) return { ok: false, error: `Could not run Archify: ${result.error.message}` };
const text = result.stdout.trim();
const start = text.indexOf('{');
if (start < 0) {
return { ok: false, error: `Archify returned no JSON receipt (exit ${result.status}). ${lastLines(result.stderr || result.stdout)}` };
}
try {
return JSON.parse(text.slice(start));
} catch (error) {
return { ok: false, error: `Archify receipt is not JSON: ${error.message}. ${lastLines(result.stderr)}` };
}
}
// archify render <type> <input.json> <output.html>
export function renderJson(type, file, output, options = {}) {
const args = ['render', type, file, output];
if (options.quality) args.push('--quality', options.quality);
if (options.repoRoot) args.push('--repo-root', options.repoRoot);
const result = run(args);
if (result.error) return { ok: false, error: `Could not run Archify: ${result.error.message}` };
if (result.status !== 0) return { ok: false, error: lastLines(result.stderr || result.stdout) || `Archify exited with ${result.status}` };
return { ok: true, output };
}
function lastLines(text, count = 6) {
return (text ?? '')
.trim()
.split('\n')
.filter((line) => line.trim())
.slice(-count)
.join('\n');
}
// Turns a validate receipt into problems: { severity, code, pointer, message }.
// Pointers come from the diagnostic subject when Archify gives one, else from
// an id quoted in the message that matches a node of the diagram.
export function receiptProblems(receipt, nodeIndex = {}, collection = 'components') {
const problems = [];
const diagnostics = Array.isArray(receipt.diagnostics) ? receipt.diagnostics : [];
for (const diagnostic of diagnostics) {
problems.push({
severity: diagnostic.severity === 'warning' ? 'warning' : 'error',
code: diagnostic.code,
pointer: pointerOf(diagnostic, nodeIndex, collection),
message: diagnostic.message,
});
}
if (!receipt.ok && diagnostics.length === 0) {
problems.push({ severity: 'error', code: receipt.stage ? `${receipt.stage}/failed` : 'archify/failed', pointer: '/', message: receipt.error ?? 'Archify validation failed.' });
}
for (const issue of receipt.composition?.issues ?? []) {
if (diagnostics.some((diagnostic) => diagnostic.message === issue.message)) continue;
problems.push({
severity: issue.severity === 'error' ? 'error' : 'warning',
code: issue.code ?? 'composition/issue',
pointer: pointerOf(issue, nodeIndex, collection),
message: issue.message ?? JSON.stringify(issue),
});
}
return problems;
}
function pointerOf(diagnostic, nodeIndex, collection) {
const subject = diagnostic.subject ?? {};
if (typeof subject.path === 'string' && subject.path !== '/') return subject.path;
if (subject.collection && Number.isInteger(subject.index)) return `/${subject.collection}/${subject.index}`;
const quoted = String(diagnostic.message ?? '').match(/"([A-Za-z][A-Za-z0-9_-]*)"/g) ?? [];
for (const token of quoted) {
const id = token.slice(1, -1);
if (Number.isInteger(nodeIndex[id])) return `/${collection}/${nodeIndex[id]}`;
}
if (typeof subject.path === 'string') return subject.path;
return undefined;
}
+283
View File
@@ -0,0 +1,283 @@
// The model: a folder with main.archify on top, diagrams (*.archify) and
// registries (*.archify-registry) below it. This module finds the root, loads
// the registry, resolves `ref` components and checks `diagram:` links.
// Formats follow FORMAT.md of the Archify Diagram Viewer design.
import fs from 'node:fs';
import path from 'node:path';
import { readSource } from './source.mjs';
import { receiptProblems, validateJson, withTempJson } from './archify.mjs';
export const MAIN = 'main.archify';
export const DIAGRAM_EXT = '.archify';
export const REGISTRY_EXT = '.archify-registry';
export const TYPES = ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle'];
export const COMPONENT_TYPES = ['frontend', 'backend', 'database', 'cloud', 'security', 'messagebus', 'external'];
export const STATE_TYPES = ['start', 'active', 'waiting', 'decision', 'success', 'failure', 'neutral', 'external'];
export const ID = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
// Node collection of each diagram kind, and the registry fields that kind accepts.
export const NODE_COLLECTION = { architecture: 'components', workflow: 'nodes', dataflow: 'nodes', sequence: 'participants', lifecycle: 'states' };
const COPY_ALL = ['type', 'label', 'sublabel', 'tag', 'icon', 'brand', 'sources'];
const COPY = {
architecture: COPY_ALL,
workflow: COPY_ALL,
dataflow: COPY_ALL,
sequence: ['type', 'label', 'sublabel', 'icon', 'brand', 'sources'],
lifecycle: COPY_ALL,
};
// What a diagram may override on a shared element. Everything else in COPY is fixed.
export const OVERRIDE = ['label', 'sublabel', 'tag'];
// Registry-only fields, never sent to Archify.
const REGISTRY_ONLY = ['description', 'diagram'];
const SKIP_DIRS = new Set(['node_modules', '.git']);
export function problem(severity, file, message, extra = {}) {
return { severity, file, ...extra, message };
}
// Nearest folder at or above `target` (file or folder) that holds main.archify.
export function findRoot(target) {
const start = path.resolve(target);
let dir = fs.existsSync(start) && fs.statSync(start).isDirectory() ? start : path.dirname(start);
for (;;) {
if (fs.existsSync(path.join(dir, MAIN))) return dir;
const parent = path.dirname(dir);
if (parent === dir) return undefined;
dir = parent;
}
}
export function relative(root, file) {
return path.relative(root, file).split(path.sep).join('/');
}
// Every diagram and registry under the root, in folder order. A second
// main.archify deeper down is reported, not nested.
export function scanModel(root) {
const diagrams = [];
const registries = [];
const nestedMains = [];
const walk = (dir) => {
const entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name)) walk(full);
} else if (entry.name === MAIN && dir !== root) nestedMains.push(full);
else if (entry.name.endsWith(REGISTRY_EXT)) registries.push(full);
else if (entry.name.endsWith(DIAGRAM_EXT)) diagrams.push(full);
}
};
walk(root);
// Root-level diagrams first (main.archify at the very top), then by folder path.
diagrams.sort((a, b) => {
const da = relative(root, path.dirname(a));
const db = relative(root, path.dirname(b));
if (da !== db) return da === '' ? -1 : db === '' ? 1 : da.localeCompare(db);
if (path.basename(a) === MAIN) return -1;
if (path.basename(b) === MAIN) return 1;
return a.localeCompare(b);
});
return { diagrams, registries, nestedMains };
}
function linkTarget(root, link) {
if (typeof link !== 'string' || !link.trim()) return { ok: false, reason: 'is not a path' };
if (path.isAbsolute(link) || link.split('/').includes('..')) return { ok: false, reason: 'must be relative to the model root, without ".."' };
if (!link.endsWith(DIAGRAM_EXT)) return { ok: false, reason: `must point at a ${DIAGRAM_EXT} file` };
const target = path.join(root, ...link.split('/'));
if (!fs.existsSync(target)) return { ok: false, reason: `does not exist under ${root}` };
return { ok: true, target };
}
// Loads every registry file under the root. Element ids are unique across
// all registry files; each element must have a type and a label.
export function loadRegistry(root, registries) {
const elements = {};
const problems = [];
const files = [];
for (const file of registries) {
const rel = relative(root, file);
const source = readSource(file);
files.push({ file: rel, elements: 0 });
for (const error of source.errors) problems.push(problem('error', rel, error.message, { line: error.line }));
if (!source.data) continue;
const doc = source.data;
if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
problems.push(problem('error', rel, 'A registry file is a YAML mapping with an "elements" key.', { line: 1 }));
continue;
}
if (doc.elements === undefined) {
problems.push(problem('error', rel, 'Missing "elements" mapping.', { line: 1 }));
continue;
}
if (typeof doc.elements !== 'object' || doc.elements === null || Array.isArray(doc.elements)) {
problems.push(problem('error', rel, '"elements" must be a mapping of id to element.', { line: source.lineOf('/elements'), pointer: '/elements' }));
continue;
}
for (const [id, element] of Object.entries(doc.elements)) {
const pointer = `/elements/${id}`;
const line = source.lineOf(pointer);
files[files.length - 1].elements += 1;
if (!ID.test(id)) problems.push(problem('error', rel, `Element id "${id}" must match ${ID}.`, { line, pointer }));
if (elements[id]) {
problems.push(problem('error', rel, `Element "${id}" is defined twice (also in ${elements[id].file}, line ${elements[id].line}).`, { line, pointer }));
continue;
}
if (typeof element !== 'object' || element === null) {
problems.push(problem('error', rel, `Element "${id}" must be a mapping.`, { line, pointer }));
continue;
}
if (!element.type) problems.push(problem('error', rel, `Element "${id}" has no type.`, { line, pointer }));
else if (!COMPONENT_TYPES.includes(element.type) && !STATE_TYPES.includes(element.type)) {
problems.push(problem('error', rel, `Element "${id}" has type "${element.type}"; Archify knows ${COMPONENT_TYPES.join(', ')} (and lifecycle state types).`, { line: source.lineOf(`${pointer}/type`), pointer: `${pointer}/type` }));
}
if (!element.label) problems.push(problem('error', rel, `Element "${id}" has no label.`, { line, pointer }));
for (const key of Object.keys(element)) {
if (!COPY_ALL.includes(key) && !REGISTRY_ONLY.includes(key)) {
problems.push(problem('warning', rel, `Element "${id}": "${key}" is not a registry field and will not reach any diagram.`, { line: source.lineOf(`${pointer}/${key}`), pointer: `${pointer}/${key}` }));
}
}
if (element.diagram !== undefined) {
const link = linkTarget(root, element.diagram);
if (!link.ok) problems.push(problem('error', rel, `Element "${id}": diagram link "${element.diagram}" ${link.reason}.`, { line: source.lineOf(`${pointer}/diagram`), pointer: `${pointer}/diagram` }));
}
elements[id] = { ...element, id, file: rel, line };
}
}
return { elements, problems, files };
}
// Reads one diagram file and expands its ref nodes from the registry.
// `root` and `elements` are undefined outside a model.
export function resolveDiagram(file, { root, elements } = {}) {
const rel = root ? relative(root, file) : path.basename(file);
const isMain = path.basename(file) === MAIN;
const source = readSource(file);
const problems = [];
const refs = [];
const result = { file: rel, path: file, isMain, source, problems, refs, model: undefined, diagramType: undefined, hasDiagram: false, resolved: undefined, nodeIndex: {} };
for (const error of source.errors) problems.push(problem('error', rel, error.message, { line: error.line }));
if (!source.data) return result;
const data = source.data;
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
problems.push(problem('error', rel, 'A diagram file is a YAML mapping.', { line: 1 }));
return result;
}
if (data.model !== undefined) {
if (!isMain) problems.push(problem('warning', rel, `Only ${MAIN} carries a "model" block; it is ignored here.`, { line: source.lineOf('/model'), pointer: '/model' }));
else if (typeof data.model !== 'object' || data.model === null) problems.push(problem('error', rel, '"model" must be a mapping with id, name and description.', { line: source.lineOf('/model'), pointer: '/model' }));
else {
result.model = data.model;
if (!data.model.id) problems.push(problem('error', rel, 'model.id is missing.', { line: source.lineOf('/model'), pointer: '/model/id' }));
else if (!ID.test(String(data.model.id))) problems.push(problem('error', rel, `model.id "${data.model.id}" must match ${ID}.`, { line: source.lineOf('/model/id'), pointer: '/model/id' }));
if (!data.model.name) problems.push(problem('error', rel, 'model.name is missing.', { line: source.lineOf('/model'), pointer: '/model/name' }));
}
} else if (isMain) {
problems.push(problem('error', rel, `${MAIN} needs a "model" block (id, name, description).`, { line: 1, pointer: '/model' }));
}
const diagram = { ...data };
delete diagram.model;
const type = diagram.diagram_type;
if (type === undefined) {
if (!isMain) problems.push(problem('error', rel, `diagram_type is missing; one of ${TYPES.join(', ')}.`, { line: 1, pointer: '/diagram_type' }));
return result;
}
result.hasDiagram = true;
if (!TYPES.includes(type)) {
problems.push(problem('error', rel, `diagram_type "${type}" is unknown; one of ${TYPES.join(', ')}.`, { line: source.lineOf('/diagram_type'), pointer: '/diagram_type' }));
return result;
}
result.diagramType = type;
const collection = NODE_COLLECTION[type];
const copyKeys = COPY[type];
const nodes = Array.isArray(diagram[collection]) ? diagram[collection] : [];
const seen = new Map();
diagram[collection] = nodes.map((node, index) => {
const pointer = `/${collection}/${index}`;
if (typeof node !== 'object' || node === null) return node;
if (node.diagram !== undefined) {
if (root) {
const link = linkTarget(root, node.diagram);
if (!link.ok) problems.push(problem('error', rel, `diagram link "${node.diagram}" ${link.reason}.`, { line: source.lineOf(`${pointer}/diagram`), pointer: `${pointer}/diagram` }));
} else {
problems.push(problem('warning', rel, `diagram link "${node.diagram}" is ignored: links need a model (no ${MAIN} above this file).`, { line: source.lineOf(`${pointer}/diagram`), pointer: `${pointer}/diagram` }));
}
}
let merged;
if (node.ref === undefined) {
const { diagram: _link, ...plain } = node;
merged = plain;
} else {
const ref = String(node.ref);
const { ref: _ref, diagram: _link, ...own } = node;
refs.push(ref);
const element = elements?.[ref];
if (!element) {
const message = root
? `Unknown element "${ref}": no registry under ${root} defines it.`
: `"${ref}" cannot be resolved: no ${MAIN} above this file, so there is no registry. Define the element inline (id, type, label) instead.`;
problems.push(problem('error', rel, message, { line: source.lineOf(`${pointer}/ref`), pointer: `${pointer}/ref` }));
merged = { id: ref, type: 'external', label: ref, ...own };
} else {
for (const key of Object.keys(own)) {
if (copyKeys.includes(key) && !OVERRIDE.includes(key)) {
problems.push(problem('error', rel, `"${key}" of shared element "${ref}" cannot be overridden in a diagram; change it in ${element.file} or use another element.`, { line: source.lineOf(`${pointer}/${key}`), pointer: `${pointer}/${key}` }));
}
}
merged = { id: own.id ?? ref };
for (const key of copyKeys) if (element[key] !== undefined) merged[key] = element[key];
for (const [key, value] of Object.entries(own)) if (key !== 'id' && (!copyKeys.includes(key) || OVERRIDE.includes(key))) merged[key] = value;
}
}
if (merged.id !== undefined) {
if (seen.has(merged.id)) problems.push(problem('error', rel, `Duplicate id "${merged.id}" (also at line ${seen.get(merged.id)}). Use "id:" next to "ref:" to draw an element twice.`, { line: source.lineOf(pointer), pointer: `${pointer}/id` }));
else seen.set(merged.id, source.lineOf(pointer));
result.nodeIndex[merged.id] = index;
}
return merged;
});
result.resolved = diagram;
return result;
}
// Runs the Archify validator on a resolved diagram and appends its problems.
export function validateResolved(entry, options = {}) {
if (!entry.resolved || !entry.diagramType) return undefined;
const name = path.basename(entry.path, DIAGRAM_EXT) || 'diagram';
const receipt = withTempJson(entry.resolved, `${name}.${entry.diagramType}`, (file) => validateJson(entry.diagramType, file, options));
for (const item of receiptProblems(receipt, entry.nodeIndex, NODE_COLLECTION[entry.diagramType])) {
entry.problems.push(problem(item.severity, entry.file, item.message, { line: item.pointer ? entry.source.lineOf(item.pointer) : undefined, pointer: item.pointer, code: item.code }));
}
return receipt;
}
// Loads a whole model: registry, every diagram resolved, model-level problems.
export function loadModel(root, options = {}) {
const scan = scanModel(root);
const registry = loadRegistry(root, scan.registries);
const problems = [...registry.problems];
for (const nested of scan.nestedMains) {
problems.push(problem('error', relative(root, nested), `A second ${MAIN} inside a model would start a nested model; nested models are not supported. Move it out or remove it.`, { line: 1 }));
}
const diagrams = scan.diagrams.map((file) => resolveDiagram(file, { root, elements: registry.elements }));
const main = diagrams.find((entry) => entry.isMain);
const usage = {};
for (const entry of diagrams) for (const ref of new Set(entry.refs)) usage[ref] = (usage[ref] ?? 0) + 1;
return { root, scan, registry, diagrams, main, usage, problems, options };
}
export function summarize(problems) {
const errors = problems.filter((item) => item.severity === 'error').length;
const warnings = problems.filter((item) => item.severity === 'warning').length;
return { errors, warnings };
}
export function presentProblem(item) {
const where = [item.file, item.line !== undefined ? `line ${item.line}` : undefined, item.pointer].filter(Boolean).join(' ');
return { severity: item.severity, file: item.file, line: item.line, pointer: item.pointer, code: item.code, message: item.message, where };
}
+42
View File
@@ -0,0 +1,42 @@
// Reads an .archify or .archify-registry file as YAML and keeps the parsed
// document so problems can be reported with the line of the offending node.
import fs from 'node:fs';
import { parseDocument, LineCounter } from '../../vendor/yaml/index.js';
export class Source {
constructor(file) {
this.file = file;
this.text = fs.readFileSync(file, 'utf8');
this.lineCounter = new LineCounter();
this.doc = parseDocument(this.text, { lineCounter: this.lineCounter, prettyErrors: true });
this.errors = this.doc.errors.map((error) => ({
line: error.linePos?.[0]?.line,
message: `YAML: ${error.message.split('\n')[0]}`,
}));
this.data = this.errors.length ? undefined : this.doc.toJS({ maxAliasCount: -1 });
}
// Line (1-based) of the node at a JSON pointer such as /components/2/ref.
lineOf(pointer) {
if (!pointer || pointer === '/') return 1;
const path = pointer
.split('/')
.slice(1)
.map((segment) => (/^\d+$/.test(segment) ? Number(segment) : segment.replace(/~1/g, '/').replace(/~0/g, '~')));
for (let depth = path.length; depth > 0; depth -= 1) {
let node;
try {
node = this.doc.getIn(path.slice(0, depth), true);
} catch {
node = undefined;
}
if (node?.range) return this.lineCounter.linePos(node.range[0]).line;
}
return undefined;
}
}
export function readSource(file) {
return new Source(file);
}
+46
View File
@@ -0,0 +1,46 @@
// Minimal YAML emitter for action results (objects, arrays, scalars).
// Output only; parsing is done by the vendored yaml package (see source.mjs).
const PLAIN = /^[A-Za-z0-9_./@+-][A-Za-z0-9_ ./@+:-]*$/;
const RESERVED = /^(true|false|null|yes|no|on|off|~|-?\d+(\.\d+)?)$/i;
function scalar(value) {
if (value === null || value === undefined) return 'null';
if (typeof value === 'boolean' || typeof value === 'number') return String(value);
const text = String(value);
if (text === '' || !PLAIN.test(text) || RESERVED.test(text) || text.endsWith(':') || text.includes(': ') || text.includes(' #')) {
return JSON.stringify(text);
}
return text;
}
function isEmpty(value) {
return (Array.isArray(value) && value.length === 0) || (value && typeof value === 'object' && Object.keys(value).length === 0);
}
function emit(value, indent) {
const pad = ' '.repeat(indent);
if (Array.isArray(value)) {
return value
.map((item) => {
if (item && typeof item === 'object' && !isEmpty(item)) {
const body = emit(item, indent + 2);
return `${pad}- ${body.slice(indent + 2)}`;
}
return `${pad}- ${isEmpty(item) ? (Array.isArray(item) ? '[]' : '{}') : scalar(item)}`;
})
.join('\n');
}
return Object.entries(value)
.filter(([, item]) => item !== undefined)
.map(([key, item]) => {
if (item && typeof item === 'object' && !isEmpty(item)) return `${pad}${key}:\n${emit(item, indent + 2)}`;
if (isEmpty(item)) return `${pad}${key}: ${Array.isArray(item) ? '[]' : '{}'}`;
return `${pad}${key}: ${scalar(item)}`;
})
.join('\n');
}
export function toYaml(value) {
return `${emit(value, 0)}\n`;
}
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env node
// The skill's own checks. Runs every action against examples/online-shop and
// a scaffolded temp model. Prints YAML (success first); exit 1 on any failure.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { toYaml } from '../services/yaml.mjs';
const scripts = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const skill = path.resolve(scripts, '..');
const example = path.join(skill, 'examples', 'online-shop');
const cli = path.join(scripts, 'src', 'cli', 'commands.mjs');
const checks = [];
function cliRun(args) {
const result = spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8', cwd: scripts, maxBuffer: 64 * 1024 * 1024 });
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
}
function yamlValue(stdout, key) {
const match = new RegExp(`^${key}: (.*)$`, 'm').exec(stdout);
return match ? match[1].trim() : undefined;
}
function check(name, command, expect) {
const result = cliRun(command);
let ok = true;
const notes = [];
try {
expect(result, notes);
} catch (error) {
ok = false;
notes.push(error.message);
}
checks.push({ name, command: `node src/cli/commands.mjs ${command.join(' ')}`, ok, notes, stdout: ok ? undefined : result.stdout.slice(0, 4000) });
}
const assert = (condition, message) => {
if (!condition) throw new Error(message);
};
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-skill-test-'));
try {
check('help prints usage', ['--help'], (r) => {
assert(r.status === 0, `exit ${r.status}`);
assert(/model-validate/.test(r.stdout) && /diagram-render/.test(r.stdout), 'actions missing from --help');
});
check('model-validate example: 4 diagrams, 0 problems', ['--action', 'model-validate', '--dir', example], (r, notes) => {
assert(r.status === 0, `exit ${r.status}`);
assert(yamlValue(r.stdout, ' errors') === '0', 'errors are not 0');
assert(yamlValue(r.stdout, ' warnings') === '0', 'warnings are not 0');
assert(yamlValue(r.stdout, ' diagrams') === '4', 'expected 4 diagrams');
assert(yamlValue(r.stdout, ' diagramsOk') === '4', 'expected 4 diagrams ok');
notes.push('4 diagrams validated with 0 problems');
});
check('model-validate from a file inside the model', ['--action', 'model-validate', '--dir', path.join(example, '0300-components', 'api.archify')], (r) => {
assert(r.status === 0, `exit ${r.status}`);
assert(yamlValue(r.stdout, 'mode') === 'model', 'root was not found from a file inside');
});
check('model-list example: 3 groups plus root-level main.archify', ['--action', 'model-list', '--dir', example], (r, notes) => {
assert(r.status === 0, `exit ${r.status}`);
assert(yamlValue(r.stdout, ' groups') === '3', 'expected 3 groups');
assert(/- file: main\.archify/.test(r.stdout), 'main.archify not listed at root level');
assert(/folder: 0100-context/.test(r.stdout) && /folder: 0200-containers/.test(r.stdout) && /folder: 0300-components/.test(r.stdout), 'group folders missing');
assert(/id: api\n(?:.*\n)*?\s+usedIn: 2/.test(r.stdout), 'api should be used in 2 diagrams');
assert(yamlValue(r.stdout, ' elements') === '11', 'expected 11 registry elements');
notes.push('3 groups, 11 elements with usage counts');
});
const resolved = path.join(tmp, 'containers.architecture.json');
check('model-resolve writes Archify JSON', ['--action', 'model-resolve', '--file', path.join(example, '0200-containers', 'online-shop.archify'), '--out', resolved], (r, notes) => {
assert(r.status === 0, `exit ${r.status}`);
const json = JSON.parse(fs.readFileSync(resolved, 'utf8'));
assert(json.model === undefined, 'model block leaked into the resolved JSON');
const api = json.components.find((c) => c.id === 'api');
assert(api && api.type === 'backend' && api.brand === 'node-js' && api.description === undefined && api.diagram === undefined, 'api was not resolved from the registry');
notes.push(`${json.components.length} components, refs expanded`);
});
check('model-resolve prints the JSON inline without --out', ['--action', 'model-resolve', '--file', path.join(example, 'main.archify')], (r) => {
assert(r.status === 0, `exit ${r.status}`);
assert(/^resolved:/m.test(r.stdout) && /label: Online Shop/.test(r.stdout), 'resolved block missing');
});
check('diagram-validate inside the model', ['--action', 'diagram-validate', '--file', path.join(example, '0100-context', 'online-shop.archify')], (r) => {
assert(r.status === 0, `exit ${r.status}`);
assert(yamlValue(r.stdout, 'archifyOk') === 'true', 'Archify did not accept the diagram');
});
const html = path.join(tmp, 'context.html');
check('diagram-render writes HTML', ['--action', 'diagram-render', '--file', path.join(example, '0100-context', 'online-shop.archify'), '--out', html], (r, notes) => {
assert(r.status === 0, `exit ${r.status}`);
assert(fs.existsSync(html) && fs.statSync(html).size > 10000, 'HTML missing or too small');
assert(/<svg/.test(fs.readFileSync(html, 'utf8')), 'HTML has no SVG');
notes.push(`${fs.statSync(html).size} bytes`);
});
// Negative cases: a broken copy of the example.
const broken = path.join(tmp, 'broken');
fs.cpSync(example, broken, { recursive: true });
fs.appendFileSync(path.join(broken, 'registry', 'people.archify-registry'), ' api:\n type: backend\n label: Dup\n');
fs.writeFileSync(
path.join(broken, '0300-components', 'bad.archify'),
'schema_version: 1\ndiagram_type: architecture\nmeta:\n title: Bad\n output: bad.html\ncomponents:\n - ref: nobody\n pos: [40, 60]\n size: [130, 60]\n - ref: db\n type: frontend\n pos: [300, 60]\n size: [130, 60]\n diagram: 0900-missing/x.archify\n - id: a\n type: backend\n label: A\n pos: [40, 200]\n size: [130, 60]\n - id: a\n type: backend\n label: A again\n pos: [40, 200]\n size: [130, 60]\n',
);
fs.writeFileSync(
path.join(broken, '0300-components', 'overlap.archify'),
'schema_version: 1\ndiagram_type: architecture\nmeta:\n title: Overlap\n output: overlap.html\ncomponents:\n - ref: api\n pos: [40, 60]\n size: [130, 60]\n - ref: db\n pos: [60, 70]\n size: [130, 60]\n',
);
check('model-validate reports duplicates, unknown refs, overrides, links, ids, layout', ['--action', 'model-validate', '--dir', broken], (r, notes) => {
assert(r.status === 1, `exit ${r.status}, expected 1`);
const text = r.stdout.replace(/\\"/g, '"');
for (const expected of ['defined twice', 'Unknown element "nobody"', '"type" of shared element "db"', 'does not exist under', 'Duplicate id "a"', 'less than 8px apart']) {
assert(text.includes(expected), `missing problem: ${expected}`);
}
assert(/pointer: \/components\/0\/ref/.test(text) && /line: 7/.test(text), 'pointer/line of the unknown ref missing');
assert(/file: 0300-components\/overlap\.archify\n(?:.*\n)*?\s+line: 7\n\s+pointer: \/components\/0\n\s+code: layout\/constraint/.test(text), 'Archify layout problem not mapped to a component line');
notes.push('all expected problems reported with file, line and pointer');
});
const single = path.join(tmp, 'single.archify');
fs.writeFileSync(single, 'schema_version: 1\ndiagram_type: architecture\nmeta:\n title: Single\n output: single.html\ncomponents:\n - { id: a, type: backend, label: A, pos: [40, 60], size: [130, 60] }\n - { id: b, type: database, label: B, pos: [300, 60], size: [130, 60] }\nconnections:\n - { from: a, to: b, label: SQL }\n');
check('diagram outside a model validates with Archify only', ['--action', 'model-validate', '--dir', single], (r) => {
assert(r.status === 0, `exit ${r.status}`);
assert(yamlValue(r.stdout, 'mode') === 'single-diagram', 'expected single-diagram mode');
});
const outside = path.join(tmp, 'outside.archify');
fs.writeFileSync(outside, 'schema_version: 1\ndiagram_type: architecture\nmeta:\n title: Outside\n output: outside.html\ncomponents:\n - { ref: api, pos: [40, 60], size: [130, 60] }\n');
check('ref outside a model is an error', ['--action', 'diagram-validate', '--file', outside], (r) => {
assert(r.status === 1, `exit ${r.status}, expected 1`);
assert(/cannot be resolved: no main\.archify above/.test(r.stdout), 'missing "no main.archify" problem');
});
const scaffold = path.join(tmp, 'scaffolded');
check('model-scaffold --c4 creates a model', ['--action', 'model-scaffold', '--dir', scaffold, '--id', 'demo-shop', '--name', 'Demo Shop', '--description', 'A demo: scaffolded model', '--c4'], (r, notes) => {
assert(r.status === 0, `exit ${r.status}`);
for (const file of ['main.archify', 'registry/people.archify-registry', 'registry/systems.archify-registry', 'registry/containers.archify-registry', '0100-context/demo-shop.archify', '0200-containers/demo-shop.archify', '0300-components/api.archify']) {
assert(fs.existsSync(path.join(scaffold, file)), `${file} not created`);
}
notes.push('7 files created');
});
check('scaffolded model validates', ['--action', 'model-validate', '--dir', scaffold], (r) => {
assert(r.status === 0, `exit ${r.status}`);
assert(yamlValue(r.stdout, ' errors') === '0' && yamlValue(r.stdout, ' warnings') === '0', 'scaffolded model has problems');
assert(yamlValue(r.stdout, ' diagrams') === '4', 'expected 4 diagrams in the scaffolded model');
});
check('scaffolded model lists 3 groups', ['--action', 'model-list', '--dir', scaffold], (r) => {
assert(r.status === 0, `exit ${r.status}`);
assert(yamlValue(r.stdout, ' groups') === '3', 'expected 3 groups');
});
check('model-scaffold refuses to overwrite', ['--action', 'model-scaffold', '--dir', scaffold, '--id', 'demo-shop', '--name', 'Demo Shop', '--c4'], (r) => {
assert(r.status === 1, `exit ${r.status}, expected 1`);
assert(/Refusing to overwrite/.test(r.stdout), 'no refusal message');
assert(fs.readFileSync(path.join(scaffold, 'main.archify'), 'utf8').includes('Demo Shop'), 'main.archify was touched');
});
const plain = path.join(tmp, 'plain');
check('model-scaffold without --c4 creates main.archify only', ['--action', 'model-scaffold', '--dir', plain, '--id', 'plain', '--name', 'Plain'], (r) => {
assert(r.status === 0, `exit ${r.status}`);
assert(fs.readdirSync(plain).join(',') === 'main.archify', `unexpected files: ${fs.readdirSync(plain).join(',')}`);
});
check('plain model validates (model block only)', ['--action', 'model-validate', '--dir', plain], (r) => {
assert(r.status === 0, `exit ${r.status}`);
});
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
const failed = checks.filter((item) => !item.ok).length;
process.stdout.write(toYaml({ success: failed === 0, summary: { checks: checks.length, passed: checks.length - failed, failed }, checks }));
process.exit(failed ? 1 : 0);
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
// Copies the Archify renderer runtime into scripts/vendor/archify and the
// yaml parser (ESM build) into scripts/vendor/yaml, so the skill's tasks run
// on plain Node 18+ without an npm install.
//
// Usage: node src/tools/sync-archify.mjs [path-to-archify-checkout] [--yaml-src <path-to-node_modules/yaml>]
// Defaults: $ARCHIFY_SRC or ../../../ws-storm/archify (relative to scripts/)
// $YAML_SRC or ../../../ws-main/archify-vscode-ext/node_modules/yaml
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const scripts = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const args = process.argv.slice(2);
const yamlIndex = args.indexOf('--yaml-src');
const yamlArg = yamlIndex >= 0 ? args.splice(yamlIndex, 2)[1] : undefined;
const source = path.resolve(args[0] || process.env.ARCHIFY_SRC || path.join(scripts, '..', '..', '..', 'ws-storm', 'archify'));
const yamlSource = path.resolve(
yamlArg || process.env.YAML_SRC || path.join(scripts, '..', '..', '..', 'ws-main', 'archify-vscode-ext', 'node_modules', 'yaml'),
);
const skill = path.join(source, 'archify');
// Runtime pieces the CLI reads at render/validate time. test/ and examples/
// are development-only and large, so they are left out.
const RUNTIME = [
'LICENSE',
'THIRD_PARTY_NOTICES.md',
'package.json',
'skill-release.json',
'assets',
'bin',
'brand-marks',
'delta',
'migrations',
'recipes',
'references',
'renderers',
'schemas',
'scripts',
];
if (!fs.existsSync(path.join(skill, 'bin', 'archify.mjs'))) {
console.error(`Archify checkout not found at ${source} (expected ${path.join(skill, 'bin', 'archify.mjs')})`);
process.exit(1);
}
if (!fs.existsSync(path.join(yamlSource, 'browser', 'dist', 'index.js'))) {
console.error(`yaml package not found at ${yamlSource} (expected browser/dist/index.js)`);
process.exit(1);
}
const vendor = path.join(scripts, 'vendor');
const archifyOut = path.join(vendor, 'archify');
fs.rmSync(archifyOut, { recursive: true, force: true });
fs.mkdirSync(archifyOut, { recursive: true });
for (const entry of RUNTIME) {
fs.cpSync(path.join(skill, entry), path.join(archifyOut, entry), { recursive: true });
}
let revision = 'unknown';
try {
revision = execFileSync('git', ['-C', source, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
} catch {}
const { version } = JSON.parse(fs.readFileSync(path.join(skill, 'package.json'), 'utf8'));
fs.writeFileSync(
path.join(vendor, 'archify.json'),
`${JSON.stringify({ upstream: 'https://github.com/tt-a1i/archify', license: 'MIT', version, revision }, null, 2)}\n`,
);
// yaml (ISC): the browser build is plain ESM and runs on Node as well.
const yamlOut = path.join(vendor, 'yaml');
fs.rmSync(yamlOut, { recursive: true, force: true });
fs.cpSync(path.join(yamlSource, 'browser', 'dist'), yamlOut, { recursive: true });
fs.copyFileSync(path.join(yamlSource, 'LICENSE'), path.join(yamlOut, 'LICENSE'));
const yamlPackage = JSON.parse(fs.readFileSync(path.join(yamlSource, 'package.json'), 'utf8'));
fs.writeFileSync(
path.join(yamlOut, 'package.json'),
`${JSON.stringify(
{
name: 'yaml',
version: yamlPackage.version,
license: yamlPackage.license,
author: yamlPackage.author,
homepage: yamlPackage.homepage,
type: 'module',
note: 'ESM build of the yaml package, copied by src/tools/sync-archify.mjs. Not an npm install.',
},
null,
2,
)}\n`,
);
console.log(`Synced archify ${version} (${revision.slice(0, 12)}) from ${source}`);
console.log(`Synced yaml ${yamlPackage.version} from ${yamlSource}`);
+6
View File
@@ -0,0 +1,6 @@
{
"upstream": "https://github.com/tt-a1i/archify",
"license": "MIT",
"version": "3.0.1",
"revision": "7158026e852f3aa6578c741e673b46d7878c92c1"
}
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2026 tt-a1i (Archify)
Copyright (c) 2025 Cocoon AI
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.
+69
View File
@@ -0,0 +1,69 @@
# Third-party notices
Archify includes optional vector data for third-party brand marks. These marks
are provided only to identify technologies and services in user-authored
diagrams. Their inclusion does not imply sponsorship, endorsement, partnership,
or affiliation with Archify.
The Archify MIT license applies to Archify's own code and content. It does not
replace the copyright licenses, trademark policies, or brand guidelines that
apply to third-party marks. Users are responsible for confirming that their
particular use is permitted.
## Simple Icons
Most of the built-in vector paths and their metadata were generated from
[Simple Icons 16.28.0](https://github.com/simple-icons/simple-icons/tree/16.28.0).
Simple Icons makes its collection work available under
[CC0 1.0 Universal](https://github.com/simple-icons/simple-icons/blob/16.28.0/LICENSE.md).
As Simple Icons explains in its
[disclaimer](https://github.com/simple-icons/simple-icons/blob/16.28.0/DISCLAIMER.md),
CC0 for the collection does not mean that every underlying icon is CC0. License
and brand-guideline metadata may be incomplete or change over time. The absence
of an individual license entry is not a grant of permission.
Archify embeds the selected icons as vector-path data and may render them in a
user-selected color. The following individual licenses were recorded in the
pinned Simple Icons 16.28.0 metadata:
| Mark | Recorded source | Recorded license | Archify treatment |
|---|---|---|---|
| Angular | [Angular press kit](https://angular.dev/press-kit) | [`CC-BY-4.0`](https://creativecommons.org/licenses/by/4.0/) | Embedded as vector-path data; color may be changed by the authored diagram. |
| Apache Airflow | [Apache logos](https://apache.org/logos) | [`Apache-2.0`](https://www.apache.org/licenses/LICENSE-2.0) | Embedded as vector-path data; Apache trademarks remain subject to the [ASF trademark policy](https://www.apache.org/foundation/marks/). |
| Apache Kafka | [Apache logos](https://apache.org/logos) | [`Apache-2.0`](https://www.apache.org/licenses/LICENSE-2.0) | Embedded as vector-path data; Apache trademarks remain subject to the [ASF trademark policy](https://www.apache.org/foundation/marks/). |
| .NET | [.NET brand repository](https://github.com/dotnet/brand/blob/c7d0f51b8ec59531332d05fb27a5b758a7a3d689/logo/dotnet-logo.svg) | [`CC0-1.0`](https://creativecommons.org/publicdomain/zero/1.0/) | Embedded as vector-path data; color may be changed by the authored diagram. |
| JavaScript | [JS community logo](https://github.com/voodootikigod/logo.js/blob/1544bdeed6d618a6cfe4f0650d04ab8d9cfa76d9/js.svg) | [`MIT`](https://github.com/voodootikigod/logo.js/blob/1544bdeed6d618a6cfe4f0650d04ab8d9cfa76d9/LICENSE) | Embedded as vector-path data; color may be changed by the authored diagram. |
| Jenkins | [Jenkins artwork source](https://get.jenkins.io/art/) | [`CC-BY-SA-3.0`](https://creativecommons.org/licenses/by-sa/3.0/) | Embedded as vector-path data; color may be changed by the authored diagram. Jenkins retains its trademark rights. |
| Rust | [Rust project](https://www.rust-lang.org) | [`CC-BY-SA-4.0`](https://creativecommons.org/licenses/by-sa/4.0/) | Embedded as vector-path data; color may be changed by the authored diagram. See the [Rust media guide](https://www.rust-lang.org/policies/media-guide). |
| Vue.js | [Vue logo source](https://github.com/vuejs/art/blob/a1c78b74569b70a25300925b4eacfefcc143b8f6/logo.svg) | [`CC-BY-NC-SA-4.0`](https://creativecommons.org/licenses/by-nc-sa/4.0/) | Embedded as vector-path data; color may be changed by the authored diagram. The non-commercial and share-alike conditions remain applicable; see the [Vue artwork terms](https://github.com/vuejs/art/blob/a1c78b74569b70a25300925b4eacfefcc143b8f6/README.md). |
The source, guideline, and known license fields for every packaged mark are
preserved in `renderers/shared/generated-brand-marks.mjs`.
## OpenAI mark
The OpenAI vector path is recorded from the
[OpenAI brand guidelines](https://openai.com/brand/), not from Simple Icons.
Use remains subject to those current guidelines and any applicable trademark
rights. Its inclusion does not state or imply endorsement by OpenAI.
## JetBrains Mono
Delivered Archify viewer artifacts embed the JetBrains Mono variable font
subsets served by Google Fonts. For characters covered by these subsets, font
selection does not depend on a network request or a locally installed copy.
Uncovered characters (including CJK) still use the system fallback stack;
browser and operating-system rasterization can differ.
JetBrains Mono is maintained at
[github.com/JetBrains/JetBrainsMono](https://github.com/JetBrains/JetBrainsMono)
and is distributed under the SIL Open Font License 1.1. The complete license
text is preserved in `assets/JetBrainsMono-OFL.txt` in the packaged Skill and
in the font CSS carried by standalone HTML and SVG exports.
## No additional rights granted
Brand names, logos, and trademarks remain the property of their respective
owners. This notice records provenance and known terms; it does not grant rights
that Archify does not hold, and it does not state that every packaged mark has
been cleared for every commercial, promotional, or redistributive use.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
File diff suppressed because it is too large. Load diff
+6936
View File
File diff suppressed because it is too large. Load diff
+91
View File
@@ -0,0 +1,91 @@
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const childPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'delivery-update-child.mjs');
const DEADLINE_MS = 1_000;
const MAX_OUTPUT_BYTES = 16 * 1_024;
function unavailable(reason) {
return { status: 'unavailable', installedVersion: null, availableVersion: null,
releaseNotes: null, checkedAt: null, source: null, noticeRequired: false,
noticeText: null, reason };
}
function normalize(result) {
if (result?.status === 'current') return {
status: 'current', installedVersion: result.installedVersion,
availableVersion: result.availableVersion, releaseNotes: null,
checkedAt: result.checkedAt, source: result.source,
noticeRequired: false, noticeText: null,
};
if (result?.status !== 'update_available') {
return unavailable(result?.reason || (result?.status === 'silent' ? 'no-update' : 'invalid-result'));
}
const noticeRequired = result.noticeRequired !== false;
const cached = result.source === 'cache';
const noticeText = noticeRequired
? `Archify ${result.severity === 'security' ? 'security update' : 'update'}: ${result.installedVersion} → ${result.latestVersion}. ${cached
? `A previous check at ${result.checkedAt} found a newer release.`
: 'A newer release is available.'} Release notes: ${result.releaseNotes}. The installed Skill has not changed; ask to snooze or ignore this reminder.`
: null;
return {
status: 'update_available',
installedVersion: result.installedVersion,
availableVersion: result.latestVersion,
releaseNotes: result.releaseNotes,
severity: result.severity,
checkedAt: result.checkedAt,
source: result.source,
noticeRequired,
noticeText,
...(result.eventKey ? { eventKey: result.eventKey } : {}),
...(result.reason ? { reason: result.reason } : {}),
...(result.suppressedUntil ? { suppressedUntil: result.suppressedUntil } : {}),
};
}
export function startDeliveryUpdateCheck({
env = process.env, deadlineMs = DEADLINE_MS, checkerPath = childPath,
} = {}) {
if (env.ARCHIFY_UPDATE_CHECK_DISABLED === '1') return Promise.resolve(unavailable('disabled'));
return new Promise((resolve) => {
let child;
try {
const deadlineNs = process.hrtime.bigint() + BigInt(Math.floor(deadlineMs * 1_000_000));
child = spawn(process.execPath, [checkerPath, String(deadlineNs)], {
env,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
});
} catch {
resolve(unavailable('runtime-unavailable'));
return;
}
let output = '';
let timedOut = false;
let overflow = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill('SIGKILL');
}, deadlineMs);
child.stdout.on('data', (chunk) => {
if (Buffer.byteLength(output) + chunk.length > MAX_OUTPUT_BYTES) {
overflow = true;
child.kill('SIGKILL');
} else output += chunk.toString('utf8');
});
child.on('error', () => {});
child.on('close', (code) => {
clearTimeout(timer);
if (overflow) return resolve(unavailable('invalid-result'));
// A synchronous renderer can delay both this callback and the parent's
// timer. A complete child result wins even if the timer fired meanwhile.
if (code === 0 && output.endsWith('\n')) {
try { return resolve(normalize(JSON.parse(output))); }
catch { return resolve(unavailable('invalid-result')); }
}
resolve(unavailable(timedOut || child.signalCode === 'SIGKILL' ? 'timeout' : 'check-failed'));
});
});
}
+895
View File
@@ -0,0 +1,895 @@
import { execFile } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { canonicalFuturePath, pathsAlias, resolveNativeOutputDirectory, resolveOutputPath } from '../renderers/shared/output-path.mjs';
import { boundedSidecarStem } from '../renderers/shared/sidecar-path.mjs';
import {
captureAtomicOutput, captureRegularFileBinding, publishRegularFileBinding,
releaseRegularFileBinding, removeOwnedRegularFile, verifyAtomicOutput,
} from '../renderers/shared/atomic-output.mjs';
import {
browserCheckSidecarPaths,
CAPTURE_VIEWPORTS,
ChromeVisualBrowser,
findChrome,
VISUAL_CHECK_VIEWPORTS,
} from './visual-check.mjs';
import { startDeliveryUpdateCheck } from './delivery-update.mjs';
export const FINALIZE_STAGES = Object.freeze(['validate', 'deliver', 'check', 'browser-check']);
const FINALIZE_UPDATE_DEADLINE_MS = 4_000;
function sha256(buffer) {
return createHash('sha256').update(buffer).digest('hex');
}
function identity(file) {
try {
const bytes = fs.readFileSync(file);
return { path: path.resolve(file), sha256: sha256(bytes), bytes: bytes.byteLength };
} catch {
return { path: path.resolve(file) };
}
}
function durationMs(start) {
return Number((process.hrtime.bigint() - start) / 1000000n);
}
function receiptWriteError(file, state) {
const error = new Error(`Could not publish finalize evidence "${file}" safely (${state.reason?.code || state.status}).`);
error.finalizeCode = 'finalize/receipt-publication';
error.finalizeEvidence = { file, state };
return error;
}
function captureReceipt(file) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const capture = captureAtomicOutput(file, { requestedEntryPolicy: 'regular-or-absent' });
if (capture.status !== 'captured') throw receiptWriteError(file, capture);
return capture;
}
function writeJsonAtomic(file, value, capture, beforeCommit) {
const serialized = Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
const temporary = path.join(path.dirname(capture.commitPath), `.archify-finalize-${randomUUID()}.json`);
let binding;
let stagedIdentity;
try {
fs.writeFileSync(temporary, serialized, { flag: 'wx' });
const metadata = fs.lstatSync(temporary, { bigint: true });
stagedIdentity = { device: metadata.dev, inode: metadata.ino };
if (capture.mode !== null) fs.chmodSync(temporary, capture.mode);
const staged = captureRegularFileBinding(temporary, {
subject: 'finalize-receipt', expectedIdentity: stagedIdentity,
expectedSha256: sha256(serialized), expectedBytes: serialized.byteLength,
...(capture.mode === null ? {} : { expectedMode: capture.mode }),
});
if (staged.status !== 'captured') throw receiptWriteError(file, staged);
binding = staged.binding;
beforeCommit();
const publication = publishRegularFileBinding(binding, temporary, capture.snapshot, { subject: 'finalize-receipt' });
if (!['committed', 'committed-with-warning'].includes(publication.status)) throw receiptWriteError(file, publication);
// Preserve recovery evidence instead of reporting a clean completion when cleanup failed.
if (publication.status === 'committed-with-warning') throw receiptWriteError(file, publication);
const next = captureReceipt(file);
if (!pathsAlias(next.commitPath, capture.commitPath)) {
throw receiptWriteError(file, { status: 'different', reason: { code: 'receipt-slot-changed' } });
}
return next;
} finally {
if (binding) releaseRegularFileBinding(binding);
if (stagedIdentity) removeOwnedRegularFile(temporary, stagedIdentity, { subject: 'finalize-receipt' });
}
}
function parsedReceipt(stdout) {
const source = String(stdout || '').trim();
if (!source) return null;
try { return JSON.parse(source); } catch { return null; }
}
function isReceiptObject(receipt) {
return Boolean(receipt && typeof receipt === 'object' && !Array.isArray(receipt));
}
function validIdentity(value) {
return isReceiptObject(value)
&& typeof value.sha256 === 'string'
&& /^[0-9a-f]{64}$/.test(value.sha256)
&& Number.isSafeInteger(value.bytes)
&& value.bytes >= 0;
}
function identitiesMatch(left, right) {
return validIdentity(left)
&& validIdentity(right)
&& left.sha256 === right.sha256
&& left.bytes === right.bytes;
}
function validReceiptId(value) {
return typeof value === 'string'
&& /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}
function allChecksPassed(checks) {
return Array.isArray(checks) && checks.length > 0
&& checks.every((check) => isReceiptObject(check) && check.ok === true);
}
function validCheckComposition(receipt, quality) {
const composition = receipt?.composition;
return isReceiptObject(composition)
&& composition.schemaVersion === 1
&& composition.profile === quality
&& composition.status === 'pass'
&& composition.summary?.errors === 0
&& (quality !== 'showcase' || composition.summary?.warnings === 0);
}
function exactViewportCoverage(entries, viewports, predicate) {
if (!Array.isArray(entries) || entries.length !== viewports.length) return false;
return viewports.every((expected) => {
const matching = entries.filter((entry) => (
isReceiptObject(entry)
&& entry.width === expected.width
&& entry.height === expected.height
&& (expected.theme === undefined || entry.requestedTheme === expected.theme)
));
return matching.length === 1 && predicate(matching[0], expected);
});
}
function validBrowserEvidence(receipt) {
return exactViewportCoverage(receipt.containment?.viewports, VISUAL_CHECK_VIEWPORTS, (entry) => (
entry.theme === 'light' && entry.ok === true
))
&& exactViewportCoverage(receipt.readability?.viewports, VISUAL_CHECK_VIEWPORTS, (entry) => (
entry.theme === 'light' && entry.ok === true && entry.readabilityOk === true
))
&& exactViewportCoverage(receipt.viewerChrome?.viewports, VISUAL_CHECK_VIEWPORTS, (entry) => (
entry.theme === 'light' && entry.ok === true && entry.viewerChromeOk === true
))
&& exactViewportCoverage(receipt.themeStates?.viewports,
[...VISUAL_CHECK_VIEWPORTS.map((viewport) => ({ ...viewport, theme: 'light' })),
...CAPTURE_VIEWPORTS.map((viewport) => ({ ...viewport, theme: 'dark' }))],
(entry, expected) => entry.ok === true
&& entry.requestedTheme === expected.theme
&& entry.resolvedTheme === expected.theme);
}
function validDeliveryValidation(receipt, quality, { allowShowcaseWarnings = false } = {}) {
const validation = receipt?.validation;
return isReceiptObject(validation)
&& Number.isInteger(validation.checkCount)
&& validation.checkCount > 0
&& validation.checksPassed === validation.checkCount
&& validation.compositionStatus === 'pass'
&& validation.errors === 0
&& validation.compositionProfile === quality
&& (quality !== 'showcase' || allowShowcaseWarnings || validation.warnings === 0);
}
function validStageReceipt(stage, receipt, quality, options) {
if (!isReceiptObject(receipt) || receipt.ok !== true) return false;
if (receipt.status && receipt.status !== 'pass') return false;
if (stage === 'validate') return receipt.command === 'validate' && Array.isArray(receipt.checks);
if (stage === 'deliver') {
return receipt.command === 'deliver'
&& receipt.schemaVersion === 1
&& validReceiptId(receipt.receiptId)
&& isReceiptObject(receipt.specification)
&& validIdentity(receipt.specification)
&& validIdentity(receipt.artifact)
&& validDeliveryValidation(receipt, quality, options);
}
if (stage === 'check') {
return validIdentity(receipt.artifact)
&& allChecksPassed(receipt.checks)
&& receipt.provenance === 'current'
&& validReceiptId(receipt.deliveryReceiptId)
&& validCheckComposition(receipt, quality);
}
return receipt.command === 'browser-check'
&& receipt.schemaVersion === 1
&& receipt.status === 'pass'
&& receipt.evidenceKind === 'automated-browser'
&& validIdentity(receipt.artifact)
&& receipt.provenance === 'current'
&& validReceiptId(receipt.deliveryReceiptId)
&& receipt.containment?.status === 'pass'
&& receipt.themeStates?.status === 'pass'
&& receipt.readability?.status === 'pass'
&& receipt.viewerChrome?.status === 'pass'
&& validBrowserEvidence(receipt);
}
function identityMismatchDiagnostic({ stage, expected, actual, expectedReceiptId, actualReceiptId, output }) {
return {
code: 'finalize/artifact-binding-mismatch',
severity: 'error',
message: `The ${stage} receipt does not prove the artifact delivered by this finalize run.`,
subject: { stage, artifact: output },
evidence: {
expectedArtifact: expected,
actualArtifact: actual,
...(expectedReceiptId ? { expectedDeliveryReceiptId: expectedReceiptId } : {}),
...(actualReceiptId ? { actualDeliveryReceiptId: actualReceiptId } : {}),
},
supportedFixes: ['finish other delivery attempts for this output, then rerun finalize from the frozen candidate'],
};
}
function showcaseWarningDiagnostics(receipt) {
const issues = receipt.validation.compositionIssues;
const warnings = receipt.validation.warnings;
if (!Array.isArray(issues) || issues.length !== warnings
|| !issues.every((issue) => isReceiptObject(issue)
&& issue.severity === 'warning'
&& typeof issue.code === 'string'
&& issue.code.startsWith('composition/')
&& typeof issue.detail === 'string' && issue.detail.trim())) {
return [{
code: 'finalize/showcase-warnings', severity: 'error',
message: `Showcase delivery reported ${warnings} composition warning(s), but did not provide matching issue details.`,
subject: { stage: 'deliver', check: 'composition' },
evidence: { warnings, reportedIssues: Array.isArray(issues) ? issues.length : null },
supportedFixes: ['run validate on the frozen candidate to inspect the composition warnings, repair them, then rerun finalize'],
}];
}
return issues.map((issue) => {
const { code, severity, detail, ...evidence } = issue;
return {
code, severity: 'error',
message: `Showcase delivery reported ${code}; zero warnings are required.`,
subject: { stage: 'deliver', check: 'composition', ...(issue.nodeId ? { nodeId: issue.nodeId } : {}) },
evidence: { reportedSeverity: severity, ...evidence },
supportedFixes: [detail.replace(/^\[[^\]]+\]\s*/, '')],
};
});
}
function stageBindingDiagnostic({ stage, receipt, expectedArtifact, expectedReceiptId, output, specification, type, deliveryValidation }) {
if (stage === 'deliver') {
if (receipt.type !== type) {
return {
code: 'finalize/delivery-type-mismatch',
severity: 'error',
message: 'The delivery command reported a different diagram type than finalize requested.',
subject: { stage: 'deliver', artifact: output },
evidence: { expectedType: type, actualType: receipt.type },
supportedFixes: ['rerun finalize with a delivery command for the requested diagram type'],
};
}
if (!identitiesMatch(receipt.specification, specification)) {
return {
code: 'finalize/candidate-changed-during-delivery',
severity: 'error',
message: 'The delivery command froze a different candidate than finalize started with.',
subject: { stage: 'validate', candidate: specification.path },
evidence: {
expectedSha256: specification.sha256,
actualSha256: receipt.specification?.sha256,
expectedBytes: specification.bytes,
actualBytes: receipt.specification?.bytes,
},
supportedFixes: ['restore the frozen candidate and rerun finalize'],
};
}
if ((!receipt.output || !pathsAlias(receipt.output, output)) || !identitiesMatch(receipt.artifact, identity(output))) {
return identityMismatchDiagnostic({
stage,
expected: receipt.artifact,
actual: identity(output),
expectedReceiptId: receipt.receiptId,
output,
});
}
return null;
}
if (stage === 'check' && deliveryValidation?.checkCount !== receipt.checks.length) {
return {
code: 'finalize/delivery-validation-mismatch',
severity: 'error',
message: 'The delivery validation count does not match the complete checker receipt for the delivered artifact.',
subject: { stage: 'check', artifact: output },
evidence: {
deliveryCheckCount: deliveryValidation?.checkCount,
checkerCheckCount: receipt.checks.length,
},
supportedFixes: ['restore the complete delivery validation receipt and rerun finalize from the frozen candidate'],
};
}
const receiptArtifactPath = receipt.artifact?.path;
const receiptFile = receipt.file;
const pathMatchesOutput = stage === 'check'
? typeof receiptFile === 'string' && pathsAlias(receiptFile, output)
: typeof receiptArtifactPath === 'string' && pathsAlias(receiptArtifactPath, output);
if (!pathMatchesOutput
|| !identitiesMatch(receipt.artifact, expectedArtifact)
|| receipt.deliveryReceiptId !== expectedReceiptId) {
return identityMismatchDiagnostic({
stage,
expected: expectedArtifact,
actual: receipt.artifact,
expectedReceiptId,
actualReceiptId: receipt.deliveryReceiptId,
output,
});
}
return null;
}
function finalArtifactDiagnostic({ output, expectedArtifact, expectedReceiptId, type, deliveryPath, inspectDelivery }) {
// Recheck dev's pending/lock, physical identity, and regular-file provenance barriers at handoff.
const provenance = inspectDelivery?.(output);
if (provenance && (!provenance.ok || provenance.receiptId !== expectedReceiptId)) {
return provenance.diagnostics?.[0] || identityMismatchDiagnostic({
stage: 'finalize', output, expected: expectedArtifact,
expectedReceiptId, actualReceiptId: provenance.receiptId,
});
}
const currentArtifact = identity(output);
let delivery;
try {
delivery = JSON.parse(fs.readFileSync(deliveryPath, 'utf8'));
} catch {
delivery = null;
}
if (identitiesMatch(currentArtifact, expectedArtifact)
&& delivery?.schemaVersion === 1
&& delivery.command === 'deliver'
&& delivery.status === 'current'
&& delivery.receiptId === expectedReceiptId
&& delivery.type === type
&& typeof delivery.output === 'string' && pathsAlias(delivery.output, output)
&& identitiesMatch(delivery.artifact, expectedArtifact)) return null;
return {
code: 'finalize/final-artifact-mismatch',
severity: 'error',
message: 'The artifact or delivery receipt changed after the final gate completed.',
subject: { stage: 'finalize', artifact: output },
evidence: {
expectedArtifact,
currentArtifact,
expectedDeliveryReceiptId: expectedReceiptId,
expectedType: type,
...(delivery?.receiptId ? { currentDeliveryReceiptId: delivery.receiptId } : {}),
...(delivery?.type ? { currentType: delivery.type } : {}),
...(delivery?.artifact ? { currentDeliveryArtifact: delivery.artifact } : {}),
},
supportedFixes: ['finish other delivery attempts for this output, then rerun finalize from the frozen candidate'],
};
}
function stageStatus(stage, exitCode, receipt, quality) {
if (exitCode === 2 || receipt?.status === 'skipped') return 'skipped';
return exitCode === 0 && validStageReceipt(stage, receipt, quality) ? 'pass' : 'fail';
}
function failureDiagnostics(stage, result, receipt, quality) {
if (Array.isArray(receipt?.diagnostics) && receipt.diagnostics.length) return receipt.diagnostics;
const invalidReceipt = (result.status ?? 1) === 0 && !validStageReceipt(stage, receipt, quality);
return [{
code: invalidReceipt ? 'finalize/invalid-stage-receipt' : 'finalize/stage-failure',
severity: 'error',
message: invalidReceipt
? `The ${stage} stage exited successfully without a valid passing receipt.`
: `The ${stage} stage did not complete successfully.`,
subject: { stage },
evidence: {
exitCode: result.status ?? 1,
...(result.signal ? { signal: result.signal } : {}),
...(result.error?.message ? { reason: result.error.message } : {}),
...(String(result.stdout || '').trim() ? { stdout: String(result.stdout).trim().slice(0, 2000) } : {}),
...(String(result.stderr || '').trim() ? { stderr: String(result.stderr).trim().slice(0, 2000) } : {}),
},
supportedFixes: [invalidReceipt
? `restore the ${stage} JSON receipt contract before retrying finalize`
: 'use the compact finalize summary to repair the named subject in place, then rerun finalize once'],
}];
}
function sidecarFile(directory, value) {
if (!value) return null;
return path.resolve(directory, value);
}
function browserEvidence(receipt, artifactPath) {
if (!receipt) return {};
const directory = receipt.sidecars?.directory
? path.resolve(receipt.sidecars.directory)
: path.dirname(path.resolve(artifactPath));
return {
...(receipt.sidecars?.receipt ? { browserCheckReceipt: sidecarFile(directory, receipt.sidecars.receipt) } : {}),
};
}
function defaultRunner({ cliPath, args, cwd, env }) {
return new Promise((resolve) => {
execFile(process.execPath, [cliPath, ...args], { cwd, env, encoding: 'utf8' }, (error, stdout, stderr) => {
resolve({
status: error ? (Number.isInteger(error.code) ? error.code : 1) : 0,
stdout,
stderr,
...(error ? { error, signal: error.signal } : {}),
});
});
});
}
function stageArguments({ stage, type, input, output, quality, repoRoot, outDir }) {
const qualityArgs = ['--quality', quality];
const repoArgs = repoRoot ? ['--repo-root', repoRoot] : [];
if (stage === 'validate') return ['validate', type, input, ...qualityArgs, ...repoArgs, '--json'];
if (stage === 'deliver') return ['deliver', type, input, output, ...qualityArgs, ...repoArgs, '--json'];
if (stage === 'check') return ['check', output, '--require-provenance'];
return [
'browser-check', output, '--json', '--require-provenance',
...(outDir ? ['--out-dir', outDir] : []),
];
}
export function defaultFinalizeReceiptPath(output, { outDir } = {}) {
// Reuse the physical artifact and cross-directory namespace used by browser evidence.
const browserReceipt = browserCheckSidecarPaths(output, { outDir }).receipt;
const stem = path.basename(browserReceipt).slice(0, -'.browser-check.json'.length);
return path.join(path.dirname(browserReceipt), `${boundedSidecarStem(stem, ['.finalize.json', '.finalize-summary.json'])}.finalize.json`);
}
export function defaultFinalizeSummaryPath(receiptPath) {
const receipt = path.resolve(receiptPath);
const component = path.basename(receipt);
const isDefault = /\.finalize\.json$/i.test(component);
const stem = component.replace(isDefault ? /\.finalize\.json$/i : /\.json$/i, '');
const suffix = isDefault ? '.finalize-summary.json' : '-summary.json';
// Already bounded default stems share the receipt namespace; avoid hashing twice.
const summaryStem = Buffer.byteLength(`${stem}${suffix}`) <= 255 && `${stem}${suffix}`.length <= 255
? stem : boundedSidecarStem(stem, [suffix]);
return path.join(path.dirname(receipt), `${summaryStem}${suffix}`);
}
function defaultDeliveryPaths(output) {
const artifact = canonicalFuturePath(output);
const delivery = artifact.replace(/\.html?$/i, '.delivery.json');
return {
provenance: delivery,
pending: delivery.replace(/\.json$/i, '-pending.json'),
lock: delivery.replace(/\.json$/i, '-lock.json'),
directoryLock: path.join(path.dirname(artifact), '.archify-delivery-lock.json'),
};
}
function reservedFinalizePaths({ input, output, outDir, deliveryPaths }) {
const browser = browserCheckSidecarPaths(output, { outDir });
return [path.resolve(input), path.resolve(output), ...Object.values(deliveryPaths(output)), browser.receipt];
}
// Node moves that remove the measured crossings and detours, most specific
// first. Only positions and sizes change; every relationship keeps its endpoints.
function placementHints({ crossings = [], detours = [], crowdedSides = [] }) {
const name = (relation) => `${relation.from} → ${relation.to}`;
const other = (relation, shared) => (relation.from === shared ? relation.to : relation.from);
const hints = [];
for (const side of crowdedSides) {
hints.push(`${side.node} has ${side.relationships} relationships facing its ${side.side} side, which fits ${Math.max(1, Math.floor((side.sidePx - 32) / 14) + 1)} ports: make that side at least ${side.neededPx}px, or move some of those neighbours so they face another side of ${side.node}.`);
}
for (const crossing of crossings) {
const shared = crossing.sharedNode;
hints.push(shared
? `${name(crossing.left)} and ${name(crossing.right)} cross next to ${shared}: move node ${other(crossing.left, shared)} or ${other(crossing.right, shared)} so the two reach ${shared} from different sides (for example one level with it, one directly above or below it).`
: `${name(crossing.left)} crosses ${name(crossing.right)}: move the node of whichever is a branch, return, or second entrance to the other side of the main path, so that relationship runs through an empty corridor.`);
}
for (const detour of detours) {
if (detour.directCorridorBlockers?.length) continue;
hints.push(`${name(detour.relationship)} needs ${detour.bends} bends: move node ${detour.relationship.from} or ${detour.relationship.to} so they share a row or column with matching centers, or sit diagonally with a clear corner.`);
}
return [...new Set(hints)].slice(0, 8);
}
export function compactFinalizeReceipt(receipt) {
const gates = {};
for (const stage of FINALIZE_STAGES) gates[stage] = receipt.stages?.[stage]?.status || 'not-run';
const allDiagnostics = receipt.diagnostics || [];
const diagnosticLimit = 8;
const selectedDiagnostics = [];
const selectedIndexes = new Set();
const seenCodes = new Set();
const seenSubjects = new Set();
const subjectKey = (entry) => {
const subject = entry?.subject;
if (!subject) return null;
if (typeof subject === 'string') return subject;
if (typeof subject !== 'object' || Array.isArray(subject)) return JSON.stringify(subject);
for (const key of ['id', 'edge', 'connection', 'relationship', 'component', 'node', 'path', 'stage']) {
if (subject[key] !== undefined) return `${key}:${JSON.stringify(subject[key])}`;
}
return JSON.stringify(subject);
};
const addDiagnostic = (entry, index) => {
if (selectedDiagnostics.length >= diagnosticLimit || selectedIndexes.has(index)) return;
selectedIndexes.add(index);
selectedDiagnostics.push({
code: entry.code,
severity: entry.severity || 'error',
message: entry.message,
...(entry.subject ? { subject: entry.subject } : {}),
...(entry.evidence && Object.keys(entry.evidence).length ? { evidence: entry.evidence } : {}),
...(Array.isArray(entry.supportedFixes) && entry.supportedFixes.length
? { supportedFixes: entry.supportedFixes.slice(0, 2) } : {}),
});
};
for (const [index, entry] of allDiagnostics.entries()) {
if (seenCodes.has(entry.code)) continue;
seenCodes.add(entry.code);
addDiagnostic(entry, index);
}
for (const [index, entry] of allDiagnostics.entries()) {
const key = subjectKey(entry);
if (!key || seenSubjects.has(key)) continue;
seenSubjects.add(key);
addDiagnostic(entry, index);
}
for (const [index, entry] of allDiagnostics.entries()) addDiagnostic(entry, index);
const compact = {
schemaVersion: 1,
ok: receipt.ok,
command: 'finalize',
status: receipt.status,
type: receipt.type,
quality: receipt.quality,
specification: receipt.specification,
artifact: receipt.artifact,
gates,
...(receipt.failedStage ? { failedStage: receipt.failedStage } : {}),
diagnostics: selectedDiagnostics,
diagnosticSummary: {
total: allDiagnostics.length,
shown: selectedDiagnostics.length,
truncated: allDiagnostics.length > selectedDiagnostics.length,
},
evidence: receipt.evidence,
...(receipt.update ? { update: receipt.update } : {}),
visualReview: receipt.visualReview || 'not-requested',
durationMs: receipt.durationMs,
};
const metrics = receipt.stages?.check?.receipt?.composition?.metrics;
const reviewSignals = Object.fromEntries([
'resolvedCrossovers', 'routesOverSuggestedBends', 'routesOverSuggestedStretch',
].filter((key) => Number.isFinite(metrics?.[key]) && metrics[key] > 0)
.map((key) => [key, metrics[key]]));
if (receipt.ok && Object.keys(reviewSignals).length) {
const routeReview = receipt.stages?.check?.receipt?.composition?.routeReview;
const hints = routeReview ? placementHints(routeReview) : [];
compact.visualReviewRecommendation = {
action: 'inspect-route-readability',
signals: reviewSignals,
reason: 'Automated gates passed, but crossings or detours still need perceptual review before claiming visual quality.',
...(routeReview ? {
affectedRoutes: {
crossings: routeReview.crossings.slice(0, 8),
detours: routeReview.detours.slice(0, 8),
truncated: routeReview.crossings.length > 8 || routeReview.detours.length > 8,
},
...(hints.length ? { hints } : {}),
repair: 'Trace these relationships at the desktop viewport. For Architecture, use references/architecture-layout-repair.md: reflow a blocked main path or tangled connected scene, and repair an isolated defect locally only when the surrounding composition is accepted. Preserve all semantic content and user-fixed geometry. Rerun finalize once after the edit.',
} : {}),
};
}
const leadingSpace = receipt.stages?.check?.receipt?.composition?.leadingSpace;
if (receipt.ok && leadingSpace?.reviewSuggested === true) {
compact.layoutReviewRecommendation = {
action: 'inspect-leading-space',
evidence: leadingSpace,
reason: 'Measured content, including routes and labels, leaves a large empty area above the diagram. This is a composition suggestion, not a failed gate.',
repair: 'Check whether that leading space is intentional. If not, reposition the connected scene nearer the canvas origin while retaining room for its actual boundaries, labels and return routes. Preserve all meaning and user-fixed geometry, then rerun finalize. No screenshot is required.',
};
}
const sequenceColumnSpace = receipt.stages?.check?.receipt?.composition?.sequenceColumnSpace;
if (receipt.ok && receipt.type === 'sequence' && sequenceColumnSpace?.reviewSuggested === true) {
compact.layoutReviewRecommendation = {
action: 'inspect-sequence-width',
evidence: sequenceColumnSpace,
reason: 'Fixed participant columns leave substantial unused space on the right, after accounting for message labels and notes. This is a layout suggestion, not a failed gate.',
repair: 'For a newly authored Sequence with omitted meta.column_fit and no user-fixed column geometry, set meta.column_fit to "spread" and rerun finalize once. Preserve participant order, every message, its y position, labels, notes and sources. Retain explicit fixed layouts and legacy inputs; report the suggestion instead of changing them automatically.',
};
}
if (!receipt.ok && receipt.status === 'fail' && receipt.failedStage === 'validate') {
compact.nextAction = {
action: 'edit-in-place',
candidate: receipt.specification?.path,
constraint: receipt.type === 'architecture'
? 'Preserve all semantics and user-fixed geometry. Use references/architecture-layout-repair.md to choose a local repair or connected-scene reflow; edit the existing candidate.'
: 'Preserve unaffected semantics and geometry; do not replace the whole candidate.',
then: 'finalize-once',
};
}
return compact;
}
export async function runFinalize({
cliPath,
type,
input,
output,
quality = 'showcase',
repoRoot,
candidateSha256,
outDir,
receiptPath,
deliveryPaths = defaultDeliveryPaths,
inspectDelivery,
cwd = process.cwd(),
env = process.env,
runCommand = defaultRunner,
runBrowserCheck,
startUpdateCheck = startDeliveryUpdateCheck,
resolveChrome = findChrome,
createBrowser = (chromePath, options) => new ChromeVisualBrowser(chromePath, options),
} = {}) {
if (!cliPath || !type || !input || !output) throw new Error('finalize requires cliPath, type, input, and output.');
const started = process.hrtime.bigint();
const startedAt = new Date().toISOString();
const resolvedInput = path.resolve(input);
const resolvedOutput = resolveOutputPath({ requestedOutput: output, inputPaths: [resolvedInput] }).outputPath;
const resolvedOutDir = outDir === undefined ? undefined : resolveNativeOutputDirectory(outDir);
const specification = identity(resolvedInput);
if (candidateSha256 && specification.sha256 !== candidateSha256) {
const error = new Error(`The candidate changed after validation: expected sha256 ${candidateSha256}, found ${specification.sha256 || 'unreadable'}.`);
error.finalizeCode = 'finalize/candidate-changed';
error.finalizeEvidence = {
candidate: resolvedInput,
expectedSha256: candidateSha256,
...(specification.sha256 ? { actualSha256: specification.sha256 } : {}),
};
throw error;
}
// Validate raw native CLI paths before creating directories or normalizing them.
if (receiptPath !== undefined) resolveOutputPath({ requestedOutput: receiptPath, inputPaths: [resolvedInput], requiredExtension: '.json' });
fs.mkdirSync(path.dirname(canonicalFuturePath(resolvedOutput)), { recursive: true });
const resolvedReceipt = resolveOutputPath({
requestedOutput: receiptPath ?? defaultFinalizeReceiptPath(resolvedOutput, { outDir: resolvedOutDir }),
inputPaths: [resolvedInput], requiredExtension: '.json',
}).outputPath;
const resolvedSummary = resolveOutputPath({
requestedOutput: defaultFinalizeSummaryPath(resolvedReceipt), inputPaths: [resolvedInput], requiredExtension: '.json',
}).outputPath;
const assertReceiptPaths = () => {
const reserved = reservedFinalizePaths({ input: resolvedInput, output: resolvedOutput, outDir: resolvedOutDir, deliveryPaths });
const receiptCollision = reserved.find((file) => pathsAlias(resolvedReceipt, file));
if (receiptCollision) throw new Error(`The finalize receipt must be distinct from the specification, artifact, and gate sidecars: "${receiptCollision}".`);
const summaryCollision = [resolvedReceipt, ...reserved].find((file) => pathsAlias(resolvedSummary, file));
if (summaryCollision) throw new Error(`The finalize summary must be distinct from the full receipt, specification, artifact, and gate sidecars: "${summaryCollision}".`);
};
assertReceiptPaths();
// Capture both paths before changing either: an unsafe summary cannot leave a new full receipt behind.
let receiptCapture = captureReceipt(resolvedReceipt);
let summaryCapture = captureReceipt(resolvedSummary);
const receipt = {
schemaVersion: 1,
ok: false,
command: 'finalize',
status: 'running',
type,
quality,
startedAt,
specification,
artifact: { path: resolvedOutput },
stages: {},
diagnostics: [],
evidence: { receipt: resolvedReceipt, summaryReceipt: resolvedSummary },
visualReview: 'not-requested',
};
const persistReceipts = () => {
assertReceiptPaths();
for (const [file, capture] of [[resolvedReceipt, receiptCapture], [resolvedSummary, summaryCapture]]) {
const current = verifyAtomicOutput(capture.snapshot);
if (current.status !== 'match') throw receiptWriteError(file, current);
}
receiptCapture = writeJsonAtomic(resolvedReceipt, receipt, receiptCapture, assertReceiptPaths);
summaryCapture = writeJsonAtomic(resolvedSummary, compactFinalizeReceipt(receipt), summaryCapture, assertReceiptPaths);
};
persistReceipts();
// The gates below usually take seconds, so a slower network can finish the
// update check in parallel instead of timing out on every delivery.
const updateCheck = startUpdateCheck({ env, deadlineMs: FINALIZE_UPDATE_DEADLINE_MS });
// Only launch/attach the blank browser here. The normal browser gate still
// verifies current delivery provenance before it consumes this one-shot factory.
const chromePath = runBrowserCheck ? resolveChrome({ env }) : null;
let browser;
let browserStartupError;
let browserTransferred = false;
if (chromePath) {
try {
browser = createBrowser(chromePath, { env });
// Deliver/check may fail before inspect() awaits startup. Handle the
// rejection now while retaining the same promise for the browser gate.
browser.sessionPromise.catch(() => {});
} catch (error) {
browserStartupError = error;
}
}
const browserFactory = () => {
if (browserStartupError) throw browserStartupError;
if (browserTransferred || !browser) throw new Error('The finalize browser is unavailable or already consumed.');
browserTransferred = true;
return browser;
};
try {
let exitCode = 0;
for (const stage of ['deliver', 'check', 'browser-check']) {
const stageStarted = process.hrtime.bigint();
const args = stageArguments({
stage,
type,
input: resolvedInput,
output: resolvedOutput,
quality,
repoRoot,
outDir: resolvedOutDir,
});
const inProcess = stage === 'browser-check' && runBrowserCheck;
let result;
if (inProcess) {
const checked = await runBrowserCheck({
artifactPath: resolvedOutput,
outDir: resolvedOutDir,
chromePath,
resolveChrome: () => chromePath,
browserFactory,
});
result = { status: checked.exitCode, stdout: JSON.stringify(checked.receipt) };
} else {
result = await runCommand({ stage, cliPath, args, cwd,
env: stage === 'deliver' ? { ...env, ARCHIFY_UPDATE_CHECK_DISABLED: '1' } : env });
}
const stageReceipt = parsedReceipt(result.stdout);
const code = result.status ?? 1;
let status = stageStatus(stage, code, stageReceipt, quality);
let stageDiagnostics;
const command = [process.execPath, cliPath, ...args];
const elapsed = durationMs(stageStarted);
if (status === 'pass') {
const deliveryReceipt = stage === 'deliver' ? stageReceipt : receipt.stages.deliver?.receipt;
const bindingDiagnostic = stageBindingDiagnostic({
stage,
receipt: stageReceipt,
expectedArtifact: deliveryReceipt?.artifact,
expectedReceiptId: deliveryReceipt?.receiptId,
output: resolvedOutput,
specification,
type,
deliveryValidation: deliveryReceipt?.validation,
});
if (bindingDiagnostic) stageDiagnostics = [bindingDiagnostic];
} else if (stage === 'deliver' && code === 0 && quality === 'showcase'
&& Number.isSafeInteger(stageReceipt?.validation?.warnings)
&& stageReceipt.validation.warnings > 0
&& validStageReceipt(stage, stageReceipt, quality, { allowShowcaseWarnings: true })) {
const bindingDiagnostic = stageBindingDiagnostic({
stage, receipt: stageReceipt, output: resolvedOutput, specification, type,
});
stageDiagnostics = bindingDiagnostic ? [bindingDiagnostic] : showcaseWarningDiagnostics(stageReceipt);
}
if (stageDiagnostics) {
status = 'fail';
}
const stageEntry = {
status,
exitCode: code,
durationMs: elapsed,
command,
...(inProcess ? { execution: 'in-process' } : {}),
...(stageReceipt ? { receipt: stageReceipt } : {}),
...(!stageReceipt && String(result.stdout || '').trim() ? { stdout: String(result.stdout).trim() } : {}),
...(String(result.stderr || '').trim() ? { stderr: String(result.stderr).trim() } : {}),
...(result.signal ? { signal: result.signal } : {}),
};
if (stage === 'deliver' && status === 'pass') {
receipt.stages.validate = {
status: 'pass',
exitCode: 0,
durationMs: null,
execution: 'embedded-in-deliver',
command,
receipt: {
schemaVersion: 1,
ok: true,
command: 'validate',
specification: stageReceipt.specification,
validation: stageReceipt.validation,
},
};
receipt.stages.deliver = stageEntry;
} else if (stage === 'deliver') {
const validationFailed = stageDiagnostics?.some((diagnostic) => diagnostic.code === 'finalize/candidate-changed-during-delivery')
|| ['input', 'render', 'check'].includes(stageReceipt?.stage);
receipt.stages.validate = validationFailed ? {
...stageEntry,
status: 'fail',
durationMs: null,
execution: 'embedded-in-deliver',
} : { status: 'not-run', execution: 'embedded-in-deliver' };
receipt.stages.deliver = validationFailed
? { status: 'not-run', execution: 'blocked-by-validate' }
: stageEntry;
} else {
receipt.stages[stage] = stageEntry;
}
if (stage === 'deliver' && stageReceipt?.artifact) receipt.artifact = {
path: resolvedOutput,
...stageReceipt.artifact,
};
if (stage === 'browser-check') {
receipt.evidence = {
receipt: resolvedReceipt,
summaryReceipt: resolvedSummary,
...browserEvidence(stageReceipt, resolvedOutput),
};
}
if (status !== 'pass') {
exitCode = status === 'skipped' ? 2 : (code || 1);
receipt.status = status;
receipt.diagnostics = stageDiagnostics || failureDiagnostics(stage, result, stageReceipt, quality);
receipt.failedStage = stage === 'deliver'
&& receipt.stages.validate.status === 'fail' ? 'validate' : stage;
break;
}
persistReceipts();
}
receipt.ok = exitCode === 0;
receipt.status = receipt.ok ? 'pass' : receipt.status === 'running' ? 'fail' : receipt.status;
if (receipt.ok) {
const deliveryReceipt = receipt.stages.deliver?.receipt;
const diagnostic = finalArtifactDiagnostic({
output: resolvedOutput,
expectedArtifact: deliveryReceipt?.artifact,
expectedReceiptId: deliveryReceipt?.receiptId,
type,
deliveryPath: deliveryPaths(resolvedOutput).provenance,
inspectDelivery,
});
if (diagnostic) {
exitCode = 1;
receipt.ok = false;
receipt.status = 'fail';
receipt.failedStage = 'finalize';
receipt.diagnostics = [diagnostic];
}
}
if (receipt.ok && !identitiesMatch(specification, identity(resolvedInput))) {
exitCode = 1;
receipt.ok = false;
receipt.status = 'fail';
receipt.failedStage = 'finalize';
receipt.diagnostics = [{
code: 'finalize/candidate-changed', severity: 'error',
message: 'The frozen candidate changed before the final handoff.',
subject: { candidate: resolvedInput },
evidence: { expected: specification, actual: identity(resolvedInput) },
supportedFixes: ['restore the frozen candidate, or finalize the edited candidate as a new attempt'],
}];
}
receipt.artifact = receipt.ok
? { path: resolvedOutput, ...receipt.stages.deliver.receipt.artifact }
: identity(resolvedOutput);
receipt.finishedAt = new Date().toISOString();
receipt.durationMs = durationMs(started);
receipt.update = await updateCheck;
persistReceipts();
return { exitCode, receipt, summary: compactFinalizeReceipt(receipt) };
} finally {
if (browser && !browserTransferred) await browser.close();
await updateCheck;
}
}
+146
View File
@@ -0,0 +1,146 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
const OPENERS = {
darwin: {
command: 'open',
method: 'open',
timeoutMs: 5000,
args: (target) => [target],
},
linux: {
command: 'xdg-open',
method: 'xdg-open',
timeoutMs: 5000,
args: (target) => [target],
},
win32: {
command: 'powershell.exe',
method: 'powershell',
// PowerShell cold starts can approach five seconds on hosted Windows
// runners. Keep the launch bounded without treating normal startup as a
// timeout.
timeoutMs: 15000,
// Keep the command constant and pass the target through a child-only
// environment variable. Paths are never interpolated into executable source.
args: () => [
'-NoProfile',
'-NonInteractive',
'-Command',
'Start-Process -FilePath $env:ARCHIFY_OPEN_TARGET',
],
},
};
function failureDetails(result, opener, timeoutMs) {
const error = result?.error;
if (error?.code === 'ENOENT') {
return {
code: 'opener/unavailable',
reason: `Could not find ${opener.command}. Install or enable the platform opener, then open the target manually.`,
systemCode: 'ENOENT',
};
}
if (error?.code === 'ETIMEDOUT') {
return {
code: 'opener/timeout',
reason: `${opener.command} did not finish within ${timeoutMs}ms. Open the target manually or retry when the system is less busy.`,
systemCode: 'ETIMEDOUT',
timeoutMs,
};
}
if (error) {
return {
code: 'opener/spawn-failed',
reason: error.message || `${opener.command} could not be started. Open the target manually.`,
...(error.code ? { systemCode: String(error.code) } : {}),
};
}
if (result?.signal) {
return {
code: 'opener/signaled',
reason: `${opener.command} was terminated by ${result.signal}. Open the target manually.`,
signal: result.signal,
};
}
if (result?.status !== 0) {
return {
code: 'opener/nonzero-exit',
reason: `${opener.command} exited with status ${result?.status ?? 'unknown'}. Open the target manually.`,
exitCode: result?.status ?? null,
};
}
return null;
}
function launchTimeout(value, fallback) {
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
}
function launchTarget(target, options = {}) {
const platform = options.platform || process.platform;
const opener = OPENERS[platform];
if (!opener) {
return {
requested: true,
status: 'unsupported',
target,
method: null,
};
}
const spawn = options.spawn || spawnSync;
const timeoutMs = launchTimeout(options.timeoutMs, opener.timeoutMs);
let result;
try {
const spawnOptions = {
encoding: 'utf8',
shell: false,
stdio: 'ignore',
timeout: timeoutMs,
windowsHide: true,
};
if (platform === 'win32') {
spawnOptions.env = {
...process.env,
ARCHIFY_OPEN_TARGET: target,
};
}
result = spawn(opener.command, opener.args(target), spawnOptions);
} catch (error) {
result = { error };
}
const failure = failureDetails(result, opener, timeoutMs);
let status = 'opened';
if (failure?.code === 'opener/unavailable') status = 'unsupported';
else if (failure) status = 'failed';
return {
requested: true,
status,
target,
method: opener.method,
...(failure ? { failure } : {}),
};
}
export function openArtifact(target, options = {}) {
return launchTarget(path.resolve(target), options);
}
export function openLoopbackUrl(target, options = {}) {
let url;
try {
url = new URL(target);
} catch {
throw new TypeError('Preview URL must be a valid loopback HTTP URL.');
}
if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port) {
throw new TypeError('Preview URL must be a loopback URL using http://127.0.0.1:<port>.');
}
if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
throw new TypeError('Preview URL must target the loopback preview root.');
}
return launchTarget(url.href, options);
}
+969
View File
@@ -0,0 +1,969 @@
import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { openLoopbackUrl } from './open-artifact.mjs';
import { resolveOutputPath } from '../renderers/shared/output-path.mjs';
import { sameLocation } from '../renderers/shared/path-semantics.mjs';
import {
captureAtomicOutput,
captureRegularFileBinding,
publishRegularFileBinding,
releaseRegularFileBinding,
removeEmptyDirectoryWithRetry,
removeOwnedRegularFile,
verifyAtomicOutput,
} from '../renderers/shared/atomic-output.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
const cliPath = path.join(here, 'archify.mjs');
const loopbackHost = '127.0.0.1';
const defaultDebounceMs = 400;
const defaultPollMs = 800;
const defaultStopGraceMs = 3000;
const defaultStopKillMs = 750;
const diagramTypes = new Set(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']);
let previewCommitSequence = 0;
function sha256(value) {
return createHash('sha256').update(value).digest('hex');
}
function sourceDigest(inputPath) {
try {
const bytes = fs.readFileSync(inputPath);
return { hash: sha256(bytes), bytes, missing: false };
} catch (error) {
return { hash: `unreadable:${error.code || 'unknown'}`, bytes: null, missing: true };
}
}
function initialAuthoredOutput(inputPath) {
try {
const source = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
if (typeof source?.meta?.output === 'string') {
return source.meta.output;
}
} catch {
// An invalid initial source still gets a status shell. Its output target is
// fixed to the same fallback that `deliver` would use after repair.
}
return undefined;
}
function previewPage() {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Archify Live Preview</title>
<style>
:root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #0b111b; }
body { display: grid; grid-template-rows: auto minmax(0, 1fr); color: #e8edf5; }
header { position: relative; z-index: 2; display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 7px 12px; border-bottom: 1px solid #253248; background: rgba(11, 17, 27, .96); box-shadow: 0 8px 22px rgba(0,0,0,.18); }
.brand { font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #9cadc6; }
#status { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; min-height: 30px; padding: 5px 10px; border: 1px solid #33435d; border-radius: 999px; background: #111b2a; font-size: 12px; white-space: nowrap; }
#status::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: #6f819d; }
body[data-state="checking"] #status::before { background: #f3b44b; box-shadow: 0 0 0 4px rgba(243,180,75,.12); }
body[data-state="verified"] #status::before { background: #45d6a8; box-shadow: 0 0 0 4px rgba(69,214,168,.12); }
body[data-state="needs-fix"] #status::before { background: #ff6f78; box-shadow: 0 0 0 4px rgba(255,111,120,.12); }
details { max-width: min(62vw, 760px); }
summary { cursor: pointer; color: #ffbdc2; font-size: 12px; }
.diagnostic { position: absolute; top: 38px; right: 12px; width: min(760px, calc(100vw - 24px)); max-height: min(44vh, 360px); overflow: auto; padding: 14px; border: 1px solid #6a3440; border-radius: 10px; background: #17131b; box-shadow: 0 14px 48px rgba(0,0,0,.42); }
pre { margin: 0 0 10px; white-space: pre-wrap; overflow-wrap: anywhere; font: 11px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; color: #f2dfe2; }
button { min-height: 32px; padding: 5px 10px; border: 1px solid #4a5d79; border-radius: 7px; background: #1a273a; color: #eef4ff; cursor: pointer; }
main { position: relative; min-height: 0; }
iframe { display: none; width: 100%; height: 100%; border: 0; background: #fff; }
body[data-has-artifact="true"] iframe { display: block; }
#empty { position: absolute; inset: 0; display: grid; place-items: center; padding: 32px; color: #91a2bc; text-align: center; background: radial-gradient(circle at 50% 38%, #15233a 0, #0b111b 55%); }
body[data-has-artifact="true"] #empty { display: none; }
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } }
</style>
</head>
<body data-state="checking" data-has-artifact="false">
<header>
<span class="brand">Archify Preview</span>
<details id="failure" hidden>
<summary role="button" aria-controls="diagnostic-panel">View diagnostic</summary>
<div class="diagnostic" id="diagnostic-panel"><pre id="diagnostic"></pre><button id="copy" type="button">Copy diagnostic</button></div>
</details>
<span id="status" role="status" aria-live="polite">Checking · generation 1</span>
</header>
<main>
<div id="empty">Waiting for the first verified diagram. Invalid input will stay here with an exact diagnostic.</div>
<iframe id="artifact" title="Verified Archify diagram"></iframe>
</main>
<script>
(function () {
'use strict';
var body = document.body;
var status = document.getElementById('status');
var failure = document.getElementById('failure');
var diagnostic = document.getElementById('diagnostic');
var artifact = document.getElementById('artifact');
var lastRevision = 0;
function render(state) {
body.dataset.state = state.status;
if (state.status === 'verified') {
status.textContent = 'Verified · rev ' + state.revision;
failure.hidden = true;
failure.open = false;
if (state.revision !== lastRevision) {
lastRevision = state.revision;
artifact.src = '/artifact.html?revision=' + encodeURIComponent(state.revision) + '&sha=' + encodeURIComponent(state.lastVerified.sha256.slice(0, 12));
body.dataset.hasArtifact = 'true';
}
} else if (state.status === 'needs-fix') {
status.textContent = 'Needs fix · ' + (state.revision ? 'showing rev ' + state.revision : 'no verified revision');
diagnostic.textContent = 'Generation ' + state.generation + ' · ' + state.failure.stage + '\\n\\n' + state.failure.message;
failure.hidden = false;
} else {
status.textContent = 'Checking · generation ' + state.generation;
failure.hidden = true;
}
}
document.getElementById('copy').addEventListener('click', function () {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(diagnostic.textContent).catch(function () {});
}
});
var events = new EventSource('/events');
events.addEventListener('state', function (event) {
try { render(JSON.parse(event.data)); } catch (_) {}
});
}());
</script>
</body>
</html>`;
}
function compactMessage(value) {
let text = String(value || 'Preview build failed without a diagnostic.').trim();
const lines = text.split(/\r?\n/);
const errorLine = lines.findIndex((line) => /^Error:\s/.test(line));
if (errorLine > 0) text = lines.slice(errorLine).join('\n');
const relevant = text.split(/\r?\n/);
const stackLine = relevant.findIndex((line, index) => index > 0 && /^\s*at\s/.test(line));
if (stackLine > 0) text = relevant.slice(0, stackLine).join('\n');
return text.length > 6000 ? `${text.slice(0, 6000)}\n… diagnostic truncated` : text;
}
function redactDiagnostic(value, paths) {
let text = compactMessage(value);
for (const [absolutePath, replacement] of paths) {
if (!absolutePath) continue;
text = text.split(absolutePath).join(replacement);
}
return text;
}
function safeJson(value) {
return JSON.stringify(value).replace(/</g, '\\u003c');
}
function responseHeaders(contentType) {
return {
'Cache-Control': 'no-store',
'Content-Type': contentType,
'Cross-Origin-Resource-Policy': 'same-origin',
'Referrer-Policy': 'no-referrer',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'SAMEORIGIN',
};
}
function parseReceipt(stdout) {
try {
return JSON.parse(stdout);
} catch {
return null;
}
}
function atomicOutputFailure(result) {
const code = result?.reason?.code || 'unclassified';
let diagnosticCode = 'output/target-indeterminate';
let message;
if (code === 'target-hardlinked' || code === 'candidate-hardlinked') {
diagnosticCode = 'output/target-hardlinked';
message = code === 'candidate-hardlinked'
? 'Preview commit candidate has multiple hard-link names and cannot be published safely.'
: 'Preview output has multiple hard-link names; atomic publication cannot update every name.';
} else if (code === 'target-not-regular-file' || code === 'candidate-not-regular-file') {
diagnosticCode = 'output/target-not-regular-file';
message = code === 'candidate-not-regular-file'
? 'Preview commit candidate is no longer a regular file.'
: 'Preview output already exists and is not a regular file.';
} else if (result?.status === 'different') {
diagnosticCode = 'output/target-changed';
message = `Preview output changed while the verified candidate was being prepared (${code}).`;
} else {
message = `Preview output stability could not be determined safely (${code}).`;
}
return {
code: diagnosticCode,
message,
evidence: { relation: result?.reason || { code } },
};
}
function atomicOutputError(result) {
const failure = atomicOutputFailure(result);
return Object.assign(new Error(failure.message), { previewFailure: failure });
}
function stagePreviewCommit(commitPath, artifact, mode) {
for (let attempt = 0; attempt < 100; attempt += 1) {
previewCommitSequence += 1;
const candidatePath = path.join(
path.dirname(commitPath),
`.archify-preview-commit-${process.pid}-${Date.now().toString(36)}-${previewCommitSequence}.tmp`,
);
let descriptor;
let identity;
try {
const noFollow = process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0);
descriptor = fs.openSync(
candidatePath,
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow,
mode ?? 0o666,
);
let metadata;
try {
metadata = fs.fstatSync(descriptor, { bigint: true });
} catch (error) {
try {
const retry = fs.fstatSync(descriptor, { bigint: true });
if (retry.isFile() && retry.ino !== 0n) {
identity = { device: retry.dev, inode: retry.ino };
}
} catch {}
throw error;
}
if (!metadata.isFile() || metadata.ino === 0n) {
throw new Error('Preview commit candidate identity could not be verified safely.');
}
identity = { device: metadata.dev, inode: metadata.ino };
fs.writeFileSync(descriptor, artifact);
if (mode !== null) fs.fchmodSync(descriptor, mode);
fs.closeSync(descriptor);
descriptor = undefined;
return { candidatePath, identity };
} catch (error) {
if (descriptor !== undefined) {
try { fs.closeSync(descriptor); } catch {}
}
if (error.code === 'EEXIST') continue;
if (identity) removeOwnedRegularFile(candidatePath, identity);
throw error;
}
}
throw Object.assign(new Error('Could not reserve a preview commit candidate beside the output.'), {
code: 'EEXIST',
errno: -17,
syscall: 'open',
});
}
function captureOwnedDirectory(directoryPath) {
const metadata = fs.lstatSync(directoryPath, { bigint: true });
if (!metadata.isDirectory() || metadata.ino === 0n) {
throw new Error('Preview staging directory identity could not be verified safely.');
}
return { device: metadata.dev, inode: metadata.ino };
}
function cleanupOwnedDirectory(directoryPath, identity) {
let current;
try {
current = fs.lstatSync(directoryPath, { bigint: true });
} catch (error) {
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return;
throw error;
}
// Preserve a replacement rather than recursively deleting a directory that
// this preview instance did not create.
if (!current.isDirectory()
|| current.ino === 0n
|| current.dev !== identity.device
|| current.ino !== identity.inode) return;
try {
removeEmptyDirectoryWithRetry(directoryPath);
} catch (error) {
// An entry whose identity was never bound to this preview may be an
// external claimant. Preserve the private directory as recovery material
// instead of recursively deleting unknown contents.
if (error?.code !== 'ENOTEMPTY' && error?.code !== 'EEXIST') throw error;
}
}
function captureOwnedDeliverySidecars(candidatePath, snapshotPath, receipt) {
const suffixes = ['.delivery.json', '.delivery-pending.json'];
const capturedSidecars = [];
for (const suffix of suffixes) {
const sidecarPath = candidatePath.replace(/\.html$/iu, suffix);
const captured = captureRegularFileBinding(sidecarPath, {
subject: 'preview-delivery-sidecar',
expectedLinks: 1,
includeContent: true,
});
if (captured.status !== 'captured') continue;
try {
const sidecar = JSON.parse(captured.content.buffer.toString('utf8'));
const commonMatches = sidecar?.schemaVersion === 1
&& sidecar.command === 'deliver'
&& sameLocation(sidecar.output, candidatePath).status === 'match'
&& (!receipt?.receiptId || sidecar.receiptId === receipt.receiptId);
const currentMatches = suffix === '.delivery.json'
&& commonMatches
&& sidecar.status === 'current'
&& receipt?.artifact?.sha256
&& sidecar.artifact?.sha256 === receipt.artifact.sha256;
const pendingMatches = suffix === '.delivery-pending.json'
&& commonMatches
&& sidecar.status === 'pending'
&& sameLocation(sidecar.input, snapshotPath).status === 'match';
if (currentMatches || pendingMatches) {
capturedSidecars.push({ path: sidecarPath, identity: captured.identity });
}
} catch {
// Preserve malformed or claimant-controlled sidecars for recovery.
} finally {
releaseRegularFileBinding(captured.binding);
}
}
return capturedSidecars;
}
export async function startPreview(options) {
const type = options.type;
if (!diagramTypes.has(type)) throw new Error(`Unknown diagram type "${type}".`);
if (options.quality && !['standard', 'showcase'].includes(options.quality)) {
throw new Error(`Unknown quality profile "${options.quality}".`);
}
const inputPath = path.resolve(options.input);
const outputRequest = {
requestedOutput: options.output,
authoredOutput: initialAuthoredOutput(inputPath),
defaultOutput: `${type}.html`,
inputPaths: [inputPath],
inputDescription: 'its JSON input',
cwd: options.cwd || process.cwd(),
};
const { outputPath } = resolveOutputPath(outputRequest);
const outputDirectory = path.dirname(outputPath);
const debounceMs = Number.isFinite(options.debounceMs) ? options.debounceMs : defaultDebounceMs;
const pollMs = Number.isFinite(options.pollMs) ? options.pollMs : defaultPollMs;
const stopGraceMs = Number.isFinite(options.stopGraceMs) ? Math.max(0, options.stopGraceMs) : defaultStopGraceMs;
const stopKillMs = Number.isFinite(options.stopKillMs) ? Math.max(0, options.stopKillMs) : defaultStopKillMs;
const shouldOpen = options.open !== false;
fs.mkdirSync(outputDirectory, { recursive: true });
// Keep cleanup bound to the physical directory selected at startup. A
// symlink or junction in the requested output parent may be redirected while
// preview is running and must not redirect recursive cleanup to a claimant.
const physicalOutputDirectory = fs.realpathSync.native(outputDirectory);
const stagingDirectory = fs.mkdtempSync(path.toNamespacedPath(
path.join(physicalOutputDirectory, '.archify-preview-'),
));
let stagingIdentity;
try {
stagingIdentity = captureOwnedDirectory(stagingDirectory);
} catch (error) {
try { fs.rmdirSync(stagingDirectory); } catch {}
throw error;
}
let port = 0;
let watcher;
let debounceTimer;
let pollTimer;
let stopGraceTimer;
let stopKillTimer;
let child;
let stopping = false;
let forceStopping = false;
let stopped = false;
let serverClosing = false;
let serverClosed = false;
let queuedHash = null;
let activeHash = null;
let lastGoodSourceHash = null;
let sourceEpoch = 0;
let activeEpoch = 0;
let pendingBuild = false;
let artifactBuffer = null;
const clients = new Set();
const sockets = new Set();
const state = {
schemaVersion: 1,
status: 'checking',
generation: 0,
revision: 0,
lastVerified: null,
failure: null,
};
let resolveClosed;
const closed = new Promise((resolve) => { resolveClosed = resolve; });
function publicState() {
return JSON.parse(JSON.stringify(state));
}
function sendState(res) {
res.write(`event: state\ndata: ${safeJson(publicState())}\n\n`);
}
function broadcast() {
for (const res of clients) sendState(res);
}
const page = Buffer.from(previewPage());
const server = http.createServer((req, res) => {
const expectedHost = `${loopbackHost}:${port}`;
if (req.headers.host !== expectedHost) {
res.writeHead(403, responseHeaders('text/plain; charset=utf-8'));
res.end('Forbidden host');
return;
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405, { ...responseHeaders('text/plain; charset=utf-8'), Allow: 'GET, HEAD' });
res.end('Method not allowed');
return;
}
let url;
try {
url = new URL(req.url, `http://${expectedHost}`);
} catch {
res.writeHead(400, responseHeaders('text/plain; charset=utf-8'));
res.end('Bad request');
return;
}
if (url.pathname === '/') {
res.writeHead(200, {
...responseHeaders('text/html; charset=utf-8'),
'Content-Security-Policy': "default-src 'none'; frame-src 'self'; connect-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'",
'Content-Length': page.byteLength,
});
if (req.method === 'HEAD') res.end();
else res.end(page);
return;
}
if (url.pathname === '/state') {
const body = Buffer.from(`${safeJson(publicState())}\n`);
res.writeHead(200, { ...responseHeaders('application/json; charset=utf-8'), 'Content-Length': body.byteLength });
if (req.method === 'HEAD') res.end();
else res.end(body);
return;
}
if (url.pathname === '/artifact.html') {
if (!artifactBuffer) {
res.writeHead(404, responseHeaders('text/plain; charset=utf-8'));
res.end('No verified artifact yet');
return;
}
res.writeHead(200, { ...responseHeaders('text/html; charset=utf-8'), 'Content-Length': artifactBuffer.byteLength });
if (req.method === 'HEAD') res.end();
else res.end(artifactBuffer);
return;
}
if (url.pathname === '/events' && req.method === 'GET') {
res.writeHead(200, {
...responseHeaders('text/event-stream; charset=utf-8'),
Connection: 'keep-alive',
});
res.write('retry: 1000\n\n');
clients.add(res);
sendState(res);
req.on('close', () => clients.delete(res));
return;
}
res.writeHead(404, responseHeaders('text/plain; charset=utf-8'));
res.end('Not found');
});
server.on('connection', (socket) => {
sockets.add(socket);
socket.once('close', () => sockets.delete(socket));
// A connection event already queued when force-stop begins must not keep
// server.close() waiting after the current sockets have been destroyed.
if (forceStopping) socket.destroy();
});
try {
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, loopbackHost, () => {
server.off('error', reject);
port = server.address().port;
resolve();
});
});
} catch (error) {
try { server.close(); } catch {}
cleanupOwnedDirectory(stagingDirectory, stagingIdentity);
throw error;
}
const url = `http://${loopbackHost}:${port}/`;
function finishStop() {
if (stopped || child || !serverClosed) return;
stopped = true;
clearTimeout(debounceTimer);
clearInterval(pollTimer);
clearTimeout(stopGraceTimer);
clearTimeout(stopKillTimer);
try {
cleanupOwnedDirectory(stagingDirectory, stagingIdentity);
} finally {
resolveClosed();
}
}
function signalActiveChild(signal) {
if (!child || child.exitCode !== null || child.signalCode !== null) return;
try {
if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal);
else child.kill(signal);
} catch (error) {
if (error.code === 'ESRCH') return;
try { child.kill(signal); } catch {}
}
}
function closeServer() {
if (serverClosing) return;
serverClosing = true;
for (const res of clients) res.end();
clients.clear();
server.close(() => {
serverClosed = true;
finishStop();
});
server.closeIdleConnections?.();
}
function startBoundedChildDrain() {
if (!child || stopGraceTimer || stopKillTimer) return;
stopGraceTimer = setTimeout(() => {
stopGraceTimer = undefined;
if (!child) return finishStop();
signalActiveChild('SIGTERM');
stopKillTimer = setTimeout(() => {
stopKillTimer = undefined;
signalActiveChild('SIGKILL');
}, stopKillMs);
}, stopGraceMs);
}
async function stop({ force = false } = {}) {
if (force) forceStopping = true;
if (!stopping) {
stopping = true;
clearTimeout(debounceTimer);
clearInterval(pollTimer);
watcher?.close();
closeServer();
}
if (forceStopping) {
for (const socket of sockets) socket.destroy();
}
if (child && force) {
clearTimeout(stopGraceTimer);
clearTimeout(stopKillTimer);
stopGraceTimer = undefined;
stopKillTimer = undefined;
signalActiveChild('SIGKILL');
} else if (child) {
startBoundedChildDrain();
} else {
finishStop();
}
return closed;
}
function publishFailure(receipt, stdout, stderr, candidatePath, snapshotPath) {
const repairDetails = receipt?.diagnostics
?.slice(0, 12)
.map((entry) => {
const fix = entry.supportedFixes?.length ? `\nFix: ${entry.supportedFixes.join('; ')}` : '';
return `[${entry.code}] ${entry.message}${fix}`;
}) || [];
const checkerDetails = receipt?.checker?.checks
?.filter((check) => !check.ok)
.flatMap((check) => check.details || [])
.filter(Boolean)
.slice(0, 12) || [];
const diagnostic = [
receipt?.error,
...(repairDetails.length ? repairDetails : checkerDetails),
].filter(Boolean).join('\n') || stderr || stdout;
state.status = 'needs-fix';
state.failure = {
stage: receipt?.stage || 'render',
...(receipt?.code ? { code: receipt.code } : {}),
...(receipt?.evidence ? { evidence: receipt.evidence } : {}),
message: redactDiagnostic(
diagnostic,
[
[inputPath, '<input.json>'],
[outputPath, '<output.html>'],
[snapshotPath, '<input.json>'],
[candidatePath, '<candidate.html>'],
[stagingDirectory, '<preview-staging>'],
[path.resolve(here, '..'), '<archify-skill>'],
[path.resolve(options.cwd || process.cwd()), '<working-directory>'],
...(options.repoRoot ? [[path.resolve(options.repoRoot), '<repo-root>']] : []),
],
),
};
broadcast();
}
function commitCandidate(candidatePath, receipt, generationHash, outputCapture) {
let candidate;
let sourceCandidateBinding;
let sourceCandidateIdentity;
let commitCandidatePath;
let commitCandidateIdentity;
let commitCandidateBinding;
try {
const sourceCapture = captureRegularFileBinding(candidatePath, {
subject: 'candidate',
expectedSha256: receipt?.artifact?.sha256,
expectedBytes: receipt?.artifact?.bytes,
includeContent: true,
});
if (sourceCapture.status !== 'captured') throw atomicOutputError(sourceCapture);
sourceCandidateBinding = sourceCapture.binding;
sourceCandidateIdentity = sourceCapture.identity;
candidate = sourceCapture.content.buffer;
const digest = sourceCapture.content.sha256;
const releasedSource = releaseRegularFileBinding(sourceCandidateBinding);
sourceCandidateBinding = undefined;
if (releasedSource.status !== 'released') throw atomicOutputError(releasedSource);
resolveOutputPath(outputRequest);
const sameArtifact = state.lastVerified?.sha256 === digest;
const currentSource = sourceDigest(inputPath);
if (currentSource.hash !== generationHash) {
return {
committed: false,
supersededBy: currentSource,
candidateIdentity: sourceCandidateIdentity,
};
}
const beforeStage = verifyAtomicOutput(outputCapture.snapshot);
if (beforeStage.status !== 'match') throw atomicOutputError(beforeStage);
({
candidatePath: commitCandidatePath,
identity: commitCandidateIdentity,
} = stagePreviewCommit(outputCapture.commitPath, candidate, outputCapture.mode));
const candidateCapture = captureRegularFileBinding(commitCandidatePath, {
subject: 'candidate',
expectedSha256: digest,
expectedBytes: candidate.byteLength,
expectedIdentity: commitCandidateIdentity,
...(outputCapture.mode === null ? {} : { expectedMode: outputCapture.mode }),
});
if (candidateCapture.status !== 'captured') throw atomicOutputError(candidateCapture);
commitCandidateBinding = candidateCapture.binding;
const beforeCommit = verifyAtomicOutput(outputCapture.snapshot);
if (beforeCommit.status !== 'match') throw atomicOutputError(beforeCommit);
const publication = publishRegularFileBinding(
commitCandidateBinding,
commitCandidatePath,
outputCapture.snapshot,
{ subject: 'candidate' },
);
if (!['committed', 'committed-with-warning'].includes(publication.status)) {
throw atomicOutputError(publication);
}
const releasedCandidate = releaseRegularFileBinding(commitCandidateBinding);
commitCandidateBinding = undefined;
if (releasedCandidate.status !== 'released') throw atomicOutputError(releasedCandidate);
commitCandidatePath = undefined;
commitCandidateIdentity = undefined;
artifactBuffer = candidate;
lastGoodSourceHash = generationHash;
state.status = 'verified';
if (!sameArtifact) {
state.revision += 1;
state.lastVerified = {
sha256: digest,
bytes: candidate.byteLength,
checksPassed: receipt.validation.checksPassed,
checkCount: receipt.validation.checkCount,
compositionProfile: receipt.validation.compositionProfile,
compositionStatus: receipt.validation.compositionStatus,
};
}
state.failure = null;
broadcast();
return { committed: true, supersededBy: null, candidateIdentity: sourceCandidateIdentity };
} catch (error) {
publishFailure({
stage: 'commit',
error: `Could not publish the verified preview: ${error.message}`,
...(error.previewFailure || {}),
}, '', '', candidatePath);
return { committed: false, supersededBy: null, candidateIdentity: sourceCandidateIdentity };
} finally {
if (sourceCandidateBinding) releaseRegularFileBinding(sourceCandidateBinding);
if (commitCandidateBinding) releaseRegularFileBinding(commitCandidateBinding);
if (commitCandidatePath && commitCandidateIdentity) {
removeOwnedRegularFile(commitCandidatePath, commitCandidateIdentity);
}
}
}
function beginBuild(digest, epoch) {
if (stopping || child) return;
activeHash = digest.hash;
activeEpoch = epoch;
state.generation += 1;
state.status = 'checking';
state.failure = null;
broadcast();
const candidatePath = path.join(stagingDirectory, `generation-${state.generation}.html`);
const snapshotPath = path.join(stagingDirectory, `generation-${state.generation}.json`);
let snapshotIdentity;
const outputCapture = captureAtomicOutput(outputPath);
if (outputCapture.status !== 'captured') {
const failure = atomicOutputFailure(outputCapture);
publishFailure(
{ stage: 'prepare', error: failure.message, ...failure },
'',
'',
candidatePath,
snapshotPath,
);
return;
}
const beforeBuild = verifyAtomicOutput(outputCapture.snapshot);
if (beforeBuild.status !== 'match') {
const failure = atomicOutputFailure(beforeBuild);
publishFailure(
{ stage: 'prepare', error: failure.message, ...failure },
'',
'',
candidatePath,
snapshotPath,
);
return;
}
if (digest.bytes !== null) {
try {
fs.writeFileSync(snapshotPath, digest.bytes, { flag: 'wx', mode: 0o600 });
const metadata = fs.lstatSync(snapshotPath, { bigint: true });
if (metadata.isFile() && metadata.ino !== 0n) {
snapshotIdentity = { device: metadata.dev, inode: metadata.ino };
}
} catch (error) {
publishFailure(
{ stage: 'prepare', error: `Could not snapshot the observed input: ${error.message}` },
'',
'',
candidatePath,
snapshotPath,
);
return;
}
}
const args = [options.deliveryCli || cliPath, 'deliver', type, snapshotPath, candidatePath, '--json'];
if (options.quality) args.push('--quality', options.quality);
if (options.repoRoot) args.push('--repo-root', path.resolve(options.repoRoot));
let stdout = '';
let stderr = '';
child = spawn(process.execPath, args, {
cwd: options.cwd || process.cwd(),
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: process.platform !== 'win32',
});
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
child.on('error', (error) => { stderr += error.message; });
child.on('close', (code) => {
const receipt = parseReceipt(stdout);
const generationEpoch = activeEpoch;
const generationHash = activeHash;
const stale = generationEpoch !== sourceEpoch;
let supersededBy = null;
let candidateIdentity;
const deliverySidecars = captureOwnedDeliverySidecars(
candidatePath,
snapshotPath,
receipt,
);
child = null;
clearTimeout(stopGraceTimer);
clearTimeout(stopKillTimer);
stopGraceTimer = undefined;
stopKillTimer = undefined;
if (!stopping && !stale && code === 0 && receipt?.ok) {
({ supersededBy, candidateIdentity } = commitCandidate(
candidatePath,
receipt,
generationHash,
outputCapture,
));
} else if (code === 0 && receipt?.ok) {
const abandonedCandidate = captureRegularFileBinding(candidatePath, {
subject: 'abandoned-preview-candidate',
expectedSha256: receipt.artifact?.sha256,
expectedBytes: receipt.artifact?.bytes,
});
if (abandonedCandidate.status === 'captured') {
candidateIdentity = abandonedCandidate.identity;
releaseRegularFileBinding(abandonedCandidate.binding);
}
} else if (!stopping && !stale) {
publishFailure(receipt, stdout, stderr, candidatePath, snapshotPath);
}
if (candidateIdentity) removeOwnedRegularFile(candidatePath, candidateIdentity);
if (snapshotIdentity) removeOwnedRegularFile(snapshotPath, snapshotIdentity, { subject: 'snapshot' });
for (const deliverySidecar of deliverySidecars) {
removeOwnedRegularFile(deliverySidecar.path, deliverySidecar.identity, {
subject: 'preview-delivery-sidecar',
});
}
if (stopping) {
finishStop();
} else if (pendingBuild || stale || supersededBy) {
pendingBuild = false;
if (supersededBy && sourceEpoch === generationEpoch) sourceEpoch += 1;
const digest = supersededBy || sourceDigest(inputPath);
queueStableBuild(digest.hash, true);
}
});
}
function queueStableBuild(hash, immediate = false) {
queuedHash = hash;
clearTimeout(debounceTimer);
const launch = () => {
if (stopping) return;
const digest = sourceDigest(inputPath);
if (digest.hash !== queuedHash) {
queueStableBuild(digest.hash);
return;
}
if (digest.hash === lastGoodSourceHash) {
if (state.status !== 'verified' && state.lastVerified) {
state.status = 'verified';
state.failure = null;
broadcast();
}
return;
}
if (child) {
pendingBuild = true;
return;
}
beginBuild(digest, sourceEpoch);
};
debounceTimer = setTimeout(launch, immediate ? 0 : debounceMs);
}
function observeSource({ immediate = false } = {}) {
const digest = sourceDigest(inputPath);
if (!immediate && digest.hash === queuedHash) return;
sourceEpoch += 1;
queueStableBuild(digest.hash, immediate);
}
if (options.watch !== false) {
try {
// `path.resolve` keeps Windows 8.3 names intact. Canonicalize short names
// and junctions before libuv opens the directory so its callback path has
// the same prefix as the watched path.
const watchedDirectory = fs.realpathSync.native(path.dirname(inputPath));
watcher = fs.watch(watchedDirectory, (event, filename) => {
// On Windows the watcher hands us just the basename; on POSIX it can be
// null. Resolve named events against the directory libuv actually opened
// so file-level case, 8.3, and hard-link aliases still identify the input.
if (
!filename
|| sameLocation(path.join(watchedDirectory, filename.toString()), inputPath).status === 'match'
) {
observeSource();
}
});
watcher.on('error', () => {
const failedWatcher = watcher;
watcher = undefined;
failedWatcher?.close();
});
} catch (error) {
await stop();
throw new Error(`Could not watch the input directory: ${error.message}`);
}
}
pollTimer = setInterval(() => observeSource(), pollMs);
let opener = null;
if (shouldOpen) {
try {
opener = openLoopbackUrl(url);
} catch {
opener = { requested: true, status: 'unsupported', target: url, method: null };
}
}
observeSource({ immediate: true });
return {
url,
input: inputPath,
output: outputPath,
opener,
state: publicState,
stop,
closed,
};
}
export async function runPreview(options) {
const preview = await startPreview(options);
console.log(`preview ${preview.url}`);
console.log(`watching ${preview.input}`);
console.log(`output ${preview.output}`);
if (preview.opener && preview.opener.status !== 'opened') {
console.error(`Could not open the preview (${preview.opener.status}). ${preview.opener.failure?.reason || 'Open it manually.'} Target: ${preview.url}`);
}
let signalCount = 0;
const stop = () => {
signalCount += 1;
if (signalCount === 1) {
console.log('\nstopping preview…');
preview.stop();
} else {
console.log('\nforcing preview shutdown…');
preview.stop({ force: true });
}
};
process.on('SIGINT', stop);
process.on('SIGTERM', stop);
await preview.closed;
process.off('SIGINT', stop);
process.off('SIGTERM', stop);
}
+41
View File
@@ -0,0 +1,41 @@
import { recoverRetiredPublication } from '../renderers/shared/atomic-output.mjs';
function usage(stream = process.stderr) {
stream.write('Usage: node bin/recover-output.mjs <private-recovery-directory> [--json]\n');
}
const args = process.argv.slice(2);
if (args.length === 1 && ['--help', '-h'].includes(args[0])) {
usage(process.stdout);
} else {
let json = false;
let recoveryDirectory;
let invalid = false;
for (const argument of args) {
if (argument === '--json' && !json) {
json = true;
} else if (argument.startsWith('-') || recoveryDirectory !== undefined) {
invalid = true;
} else {
recoveryDirectory = argument;
}
}
if (invalid || recoveryDirectory === undefined) {
usage();
process.exitCode = 64;
} else {
const result = recoverRetiredPublication(recoveryDirectory);
if (json) {
process.stdout.write(`${JSON.stringify(result)}\n`);
} else {
const reason = result.reason?.code || 'publication-recovery-unknown';
process.stdout.write(`${result.status}: ${reason}\n`);
}
// A complete prior recovery is idempotent. A preserved target is a safe,
// deliberate non-action that needs the operator to inspect the claimant.
process.exitCode = ['recovered', 'absent'].includes(result.status)
? 0
: result.status === 'preserved' ? 2 : 1;
}
}
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
# Built-in brand marks
Archify ships a bounded catalogue of 107 commonly used brands for architecture,
workflow, sequence, data-flow, and lifecycle nodes. The mark is optional authored
identity: it never replaces the node's semantic `type`, color, label, or
relationships.
Unknown sites are handled by an explicit two-stage workflow. Run
`node bin/archify.mjs brands capture <url> --json`, then author the returned
digest-pinned `brand` value. Normal render and validate commands do not perform
an unpinned capture, and changed or unavailable content fails closed.
Most vector paths and brand metadata are generated from Simple Icons 16.28.0.
The OpenAI mark is traced to OpenAI's official brand guidelines. Every generated
entry records its source and, when available upstream, its guidelines and license
metadata in `renderers/shared/generated-brand-marks.mjs`.
Brand names and logos may be trademarks of their respective owners. Simple
Icons' CC0 license covers its collection work, not every underlying trademark or
artwork. Contributors must review the recorded source, current brand guidelines,
and intended referential use before adding or updating a mark. Archify does not
imply sponsorship, endorsement, or partnership.
Edit `catalog.json`, then regenerate the committed zero-runtime-dependency bundle:
```bash
npm run generate:brand-marks
npm run check:brand-marks
```
Do not hand-edit `renderers/shared/generated-brand-marks.mjs`.
+131
View File
@@ -0,0 +1,131 @@
{
"schemaVersion": 1,
"marks": [
{
"id": "openai",
"title": "OpenAI",
"category": "ai",
"aliases": ["chatgpt", "gpt", "codex"],
"domains": ["openai.com", "chatgpt.com"],
"custom": {
"viewBox": 20,
"hex": "000000",
"path": "M11.248 18.25q-.825 0-1.568-.314a4.3 4.3 0 0 1-1.32-.874 4 4 0 0 1-1.304.214 4 4 0 0 1-2.046-.544 4.27 4.27 0 0 1-1.518-1.485 4 4 0 0 1-.56-2.095q0-.48.131-1.04A4.4 4.4 0 0 1 2.04 10.71a4.07 4.07 0 0 1 .017-3.4 4.2 4.2 0 0 1 1.056-1.418 3.8 3.8 0 0 1 1.6-.842 3.9 3.9 0 0 1 .76-1.683q.593-.759 1.451-1.188a4.04 4.04 0 0 1 1.832-.429q.825 0 1.567.313.742.314 1.32.875a4 4 0 0 1 1.304-.215q1.106 0 2.046.545a4.14 4.14 0 0 1 1.501 1.485q.578.941.578 2.095 0 .48-.132 1.04.66.61 1.023 1.419.363.792.363 1.666 0 .892-.38 1.717a4.3 4.3 0 0 1-1.072 1.435 3.8 3.8 0 0 1-1.584.825 3.8 3.8 0 0 1-.775 1.683 4.06 4.06 0 0 1-1.436 1.188 4.04 4.04 0 0 1-1.832.429m-4.076-2.062q.825 0 1.435-.347l3.103-1.782a.36.36 0 0 0 .164-.313v-1.42L7.881 14.62a.67.67 0 0 1-.726 0l-3.118-1.798a.5.5 0 0 1-.017.115v.198q0 .841.396 1.551.413.693 1.139 1.089a3.2 3.2 0 0 0 1.617.412m.165-2.69a.4.4 0 0 0 .181.05q.083 0 .165-.05l1.238-.71-3.977-2.31a.7.7 0 0 1-.363-.643v-3.58q-.825.362-1.32 1.122a2.9 2.9 0 0 0-.495 1.65q0 .809.413 1.55.412.743 1.072 1.123zm3.91 3.663q.875 0 1.585-.396a2.96 2.96 0 0 0 1.534-2.64v-3.564a.32.32 0 0 0-.165-.297l-1.254-.726v4.604a.7.7 0 0 1-.363.643l-3.119 1.799a3 3 0 0 0 1.783.577m.627-6.039V8.878L10.01 7.822 8.129 8.878v2.244l1.881 1.056zM7.057 5.859a.7.7 0 0 1 .363-.644l3.119-1.798a3 3 0 0 0-1.782-.578q-.874 0-1.584.396A2.96 2.96 0 0 0 6.05 4.324a3.07 3.07 0 0 0-.396 1.551v3.547q0 .199.165.314l1.237.726zm8.383 7.887q.825-.364 1.303-1.123.495-.758.495-1.65a3.15 3.15 0 0 0-.412-1.55q-.413-.743-1.073-1.123l-3.086-1.782q-.099-.065-.181-.049a.3.3 0 0 0-.165.05l-1.238.692 3.993 2.327a.6.6 0 0 1 .264.264.64.64 0 0 1 .1.363zm-3.317-8.382a.63.63 0 0 1 .726 0l3.135 1.831v-.297q0-.792-.396-1.501a2.86 2.86 0 0 0-1.105-1.155q-.71-.43-1.65-.43-.825 0-1.436.347L8.294 5.941a.36.36 0 0 0-.165.314v1.418z",
"source": "https://openai.com/brand/",
"guidelines": "https://openai.com/brand/"
}
},
{ "id": "claude", "category": "ai", "simpleIcon": "claude", "aliases": ["claude-ai"], "domains": ["claude.ai"] },
{ "id": "anthropic", "category": "ai", "simpleIcon": "anthropic", "domains": ["anthropic.com"] },
{ "id": "google-gemini", "category": "ai", "simpleIcon": "googlegemini", "aliases": ["gemini"], "domains": ["gemini.google.com"] },
{ "id": "deepseek", "category": "ai", "simpleIcon": "deepseek", "domains": ["deepseek.com"] },
{ "id": "qwen", "category": "ai", "simpleIcon": "qwen", "domains": ["qwen.ai"] },
{ "id": "meta", "category": "ai", "simpleIcon": "meta", "aliases": ["llama"], "domains": ["meta.com"] },
{ "id": "mistral-ai", "category": "ai", "simpleIcon": "mistralai", "aliases": ["mistral"], "domains": ["mistral.ai"] },
{ "id": "hugging-face", "category": "ai", "simpleIcon": "huggingface", "aliases": ["huggingface"], "domains": ["huggingface.co"] },
{ "id": "ollama", "category": "ai", "simpleIcon": "ollama", "domains": ["ollama.com"] },
{ "id": "openrouter", "category": "ai", "simpleIcon": "openrouter", "aliases": ["open-router"], "domains": ["openrouter.ai"] },
{ "id": "perplexity", "category": "ai", "simpleIcon": "perplexity", "domains": ["perplexity.ai"] },
{ "id": "replicate", "category": "ai", "simpleIcon": "replicate", "domains": ["replicate.com"] },
{ "id": "google-cloud", "category": "cloud", "simpleIcon": "googlecloud", "aliases": ["gcp", "googlecloud"], "domains": ["cloud.google.com"] },
{ "id": "cloudflare", "category": "cloud", "simpleIcon": "cloudflare", "domains": ["cloudflare.com"] },
{ "id": "vercel", "category": "cloud", "simpleIcon": "vercel", "domains": ["vercel.com"] },
{ "id": "netlify", "category": "cloud", "simpleIcon": "netlify", "domains": ["netlify.com"] },
{ "id": "digitalocean", "category": "cloud", "simpleIcon": "digitalocean", "aliases": ["digital-ocean"], "domains": ["digitalocean.com"] },
{ "id": "render", "category": "cloud", "simpleIcon": "render", "domains": ["render.com"] },
{ "id": "railway", "category": "cloud", "simpleIcon": "railway", "domains": ["railway.com", "railway.app"] },
{ "id": "fly-io", "category": "cloud", "simpleIcon": "flydotio", "aliases": ["fly.io"], "domains": ["fly.io"] },
{ "id": "cloudinary", "category": "cloud", "simpleIcon": "cloudinary", "domains": ["cloudinary.com"] },
{ "id": "alibaba-cloud", "category": "cloud", "simpleIcon": "alibabacloud", "aliases": ["aliyun"], "domains": ["alibabacloud.com", "aliyun.com"] },
{ "id": "firebase", "category": "cloud", "simpleIcon": "firebase", "domains": ["firebase.google.com"] },
{ "id": "supabase", "category": "cloud", "simpleIcon": "supabase", "domains": ["supabase.com"] },
{ "id": "neon", "category": "cloud", "simpleIcon": "neon", "domains": ["neon.tech"] },
{ "id": "github", "category": "engineering", "simpleIcon": "github", "domains": ["github.com"] },
{ "id": "gitlab", "category": "engineering", "simpleIcon": "gitlab", "domains": ["gitlab.com"] },
{ "id": "bitbucket", "category": "engineering", "simpleIcon": "bitbucket", "domains": ["bitbucket.org"] },
{ "id": "docker", "category": "engineering", "simpleIcon": "docker", "domains": ["docker.com"] },
{ "id": "kubernetes", "category": "engineering", "simpleIcon": "kubernetes", "aliases": ["k8s"], "domains": ["kubernetes.io"] },
{ "id": "terraform", "category": "engineering", "simpleIcon": "terraform", "domains": ["terraform.io"] },
{ "id": "pulumi", "category": "engineering", "simpleIcon": "pulumi", "domains": ["pulumi.com"] },
{ "id": "ansible", "category": "engineering", "simpleIcon": "ansible", "domains": ["ansible.com"] },
{ "id": "jenkins", "category": "engineering", "simpleIcon": "jenkins", "domains": ["jenkins.io"] },
{ "id": "circleci", "category": "engineering", "simpleIcon": "circleci", "aliases": ["circle-ci"], "domains": ["circleci.com"] },
{ "id": "github-actions", "category": "engineering", "simpleIcon": "githubactions" },
{ "id": "argo", "category": "engineering", "simpleIcon": "argo", "aliases": ["argocd", "argo-cd"], "domains": ["argoproj.github.io"] },
{ "id": "helm", "category": "engineering", "simpleIcon": "helm", "domains": ["helm.sh"] },
{ "id": "grafana", "category": "engineering", "simpleIcon": "grafana", "domains": ["grafana.com"] },
{ "id": "prometheus", "category": "engineering", "simpleIcon": "prometheus", "domains": ["prometheus.io"] },
{ "id": "sentry", "category": "engineering", "simpleIcon": "sentry", "domains": ["sentry.io"] },
{ "id": "datadog", "category": "engineering", "simpleIcon": "datadog", "domains": ["datadoghq.com"] },
{ "id": "pagerduty", "category": "engineering", "simpleIcon": "pagerduty", "aliases": ["pager-duty"], "domains": ["pagerduty.com"] },
{ "id": "postgresql", "category": "data", "simpleIcon": "postgresql", "aliases": ["postgres"], "domains": ["postgresql.org"] },
{ "id": "mysql", "category": "data", "simpleIcon": "mysql", "domains": ["mysql.com"] },
{ "id": "mongodb", "category": "data", "simpleIcon": "mongodb", "aliases": ["mongo"], "domains": ["mongodb.com"] },
{ "id": "redis", "category": "data", "simpleIcon": "redis", "domains": ["redis.io"] },
{ "id": "apache-kafka", "category": "data", "simpleIcon": "apachekafka", "aliases": ["kafka"], "domains": ["kafka.apache.org"] },
{ "id": "rabbitmq", "category": "data", "simpleIcon": "rabbitmq", "aliases": ["rabbit-mq"], "domains": ["rabbitmq.com"] },
{ "id": "clickhouse", "category": "data", "simpleIcon": "clickhouse", "domains": ["clickhouse.com"] },
{ "id": "elasticsearch", "category": "data", "simpleIcon": "elasticsearch", "aliases": ["elastic"], "domains": ["elastic.co"] },
{ "id": "opensearch", "category": "data", "simpleIcon": "opensearch", "aliases": ["open-search"], "domains": ["opensearch.org"] },
{ "id": "snowflake", "category": "data", "simpleIcon": "snowflake", "domains": ["snowflake.com"] },
{ "id": "databricks", "category": "data", "simpleIcon": "databricks", "domains": ["databricks.com"] },
{ "id": "planetscale", "category": "data", "simpleIcon": "planetscale", "aliases": ["planet-scale"], "domains": ["planetscale.com"] },
{ "id": "prisma", "category": "data", "simpleIcon": "prisma", "domains": ["prisma.io"] },
{ "id": "sqlite", "category": "data", "simpleIcon": "sqlite", "domains": ["sqlite.org"] },
{ "id": "mariadb", "category": "data", "simpleIcon": "mariadb", "aliases": ["maria-db"], "domains": ["mariadb.org"] },
{ "id": "influxdb", "category": "data", "simpleIcon": "influxdb", "aliases": ["influx-db"], "domains": ["influxdata.com"] },
{ "id": "apache-airflow", "category": "data", "simpleIcon": "apacheairflow", "aliases": ["airflow"], "domains": ["airflow.apache.org"] },
{ "id": "notion", "category": "collaboration", "simpleIcon": "notion", "domains": ["notion.so"] },
{ "id": "figma", "category": "collaboration", "simpleIcon": "figma", "domains": ["figma.com"] },
{ "id": "jira", "category": "collaboration", "simpleIcon": "jira", "domains": ["atlassian.com"] },
{ "id": "linear", "category": "collaboration", "simpleIcon": "linear", "domains": ["linear.app"] },
{ "id": "discord", "category": "collaboration", "simpleIcon": "discord", "domains": ["discord.com"] },
{ "id": "zoom", "category": "collaboration", "simpleIcon": "zoom", "domains": ["zoom.us"] },
{ "id": "trello", "category": "collaboration", "simpleIcon": "trello", "domains": ["trello.com"] },
{ "id": "asana", "category": "collaboration", "simpleIcon": "asana", "domains": ["asana.com"] },
{ "id": "airtable", "category": "collaboration", "simpleIcon": "airtable", "domains": ["airtable.com"] },
{ "id": "miro", "category": "collaboration", "simpleIcon": "miro", "domains": ["miro.com"] },
{ "id": "stripe", "category": "business", "simpleIcon": "stripe", "domains": ["stripe.com"] },
{ "id": "shopify", "category": "business", "simpleIcon": "shopify", "domains": ["shopify.com"] },
{ "id": "hubspot", "category": "business", "simpleIcon": "hubspot", "domains": ["hubspot.com"] },
{ "id": "paypal", "category": "business", "simpleIcon": "paypal", "domains": ["paypal.com"] },
{ "id": "intercom", "category": "business", "simpleIcon": "intercom", "domains": ["intercom.com"] },
{ "id": "zendesk", "category": "business", "simpleIcon": "zendesk", "domains": ["zendesk.com"] },
{ "id": "wordpress", "category": "business", "simpleIcon": "wordpress", "domains": ["wordpress.org", "wordpress.com"] },
{ "id": "woocommerce", "category": "business", "simpleIcon": "woocommerce", "aliases": ["woo-commerce"], "domains": ["woocommerce.com"] },
{ "id": "wechat", "category": "channel", "simpleIcon": "wechat", "aliases": ["weixin", "微信"], "domains": ["weixin.qq.com"] },
{ "id": "youtube", "category": "channel", "simpleIcon": "youtube", "domains": ["youtube.com", "youtu.be"] },
{ "id": "tiktok", "category": "channel", "simpleIcon": "tiktok", "aliases": ["douyin", "抖音"], "domains": ["tiktok.com", "douyin.com"] },
{ "id": "x", "category": "channel", "simpleIcon": "x", "aliases": ["twitter"], "domains": ["x.com", "twitter.com"] },
{ "id": "instagram", "category": "channel", "simpleIcon": "instagram", "domains": ["instagram.com"] },
{ "id": "facebook", "category": "channel", "simpleIcon": "facebook", "domains": ["facebook.com"] },
{ "id": "reddit", "category": "channel", "simpleIcon": "reddit", "domains": ["reddit.com"] },
{ "id": "telegram", "category": "channel", "simpleIcon": "telegram", "domains": ["telegram.org", "t.me"] },
{ "id": "whatsapp", "category": "channel", "simpleIcon": "whatsapp", "domains": ["whatsapp.com"] },
{ "id": "pinterest", "category": "channel", "simpleIcon": "pinterest", "domains": ["pinterest.com"] },
{ "id": "python", "category": "language", "simpleIcon": "python", "domains": ["python.org"] },
{ "id": "typescript", "category": "language", "simpleIcon": "typescript", "aliases": ["ts"], "domains": ["typescriptlang.org"] },
{ "id": "javascript", "category": "language", "simpleIcon": "javascript", "aliases": ["js"] },
{ "id": "go", "category": "language", "simpleIcon": "go", "aliases": ["golang"], "domains": ["go.dev"] },
{ "id": "rust", "category": "language", "simpleIcon": "rust", "domains": ["rust-lang.org"] },
{ "id": "node-js", "category": "framework", "simpleIcon": "nodedotjs", "aliases": ["node", "nodejs"], "domains": ["nodejs.org"] },
{ "id": "react", "category": "framework", "simpleIcon": "react", "aliases": ["reactjs"], "domains": ["react.dev"] },
{ "id": "vue", "category": "framework", "simpleIcon": "vuedotjs", "aliases": ["vuejs", "vue.js"], "domains": ["vuejs.org"] },
{ "id": "next-js", "category": "framework", "simpleIcon": "nextdotjs", "aliases": ["nextjs", "next.js"], "domains": ["nextjs.org"] },
{ "id": "pytorch", "category": "framework", "simpleIcon": "pytorch", "domains": ["pytorch.org"] },
{ "id": "tensorflow", "category": "framework", "simpleIcon": "tensorflow", "domains": ["tensorflow.org"] },
{ "id": "angular", "category": "framework", "simpleIcon": "angular", "domains": ["angular.dev"] },
{ "id": "svelte", "category": "framework", "simpleIcon": "svelte", "domains": ["svelte.dev"] },
{ "id": "django", "category": "framework", "simpleIcon": "django", "domains": ["djangoproject.com"] },
{ "id": "flask", "category": "framework", "simpleIcon": "flask", "domains": ["palletsprojects.com"] },
{ "id": "fastapi", "category": "framework", "simpleIcon": "fastapi", "domains": ["fastapi.tiangolo.com"] },
{ "id": "spring", "category": "framework", "simpleIcon": "spring", "aliases": ["spring-boot"], "domains": ["spring.io"] },
{ "id": "dotnet", "category": "framework", "simpleIcon": "dotnet", "aliases": [".net"], "domains": ["dotnet.microsoft.com"] }
]
}
File diff suppressed because it is too large. Load diff
+280
View File
@@ -0,0 +1,280 @@
import { compileWorkflow } from '../renderers/workflow/workflow-compiler.mjs';
import {
createMappedWorkflowCandidate,
intrinsicWorkflow,
planningWorkflow,
} from '../renderers/workflow/workflow-migration-geometry.mjs';
import { validateSchema } from '../renderers/shared/validator.mjs';
export { createHorizontalRankMapper } from '../renderers/workflow/workflow-migration-geometry.mjs';
const TARGET_SCHEMA_VERSION = 2;
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function diagnostic({ code, message, subject = {}, evidence = {}, supportedFixes = [] }) {
return {
code,
severity: 'error',
message,
subject,
evidence,
supportedFixes,
};
}
function schemaDiagnostics(workflow) {
try {
validateSchema('workflow', workflow);
return [];
} catch (error) {
if (Array.isArray(error?.archifyDiagnostics)) {
return error.archifyDiagnostics.map((entry) => ({ ...entry }));
}
throw error;
}
}
function legacyLayoutProbe(workflow, qualityProfile) {
// The probe discovers fixed-v1 rank centers, not authored canvas capacity.
// Omitting viewBox lets a capacity-only legacy failure reach the v2 compiler,
// which can measure and monotonically expand the real migrated document.
const probe = {
schema_version: 1,
diagram_type: 'workflow',
meta: {
title: workflow.meta.title,
output: workflow.meta.output,
...(workflow.meta.locale ? { locale: workflow.meta.locale } : {}),
legend: { mode: 'hidden' },
},
lanes: clone(workflow.lanes),
nodes: [{
id: 'migration_probe',
lane: workflow.lanes[0].id,
col: 0,
type: 'backend',
label: 'Probe',
}],
edges: [],
};
return compileWorkflow({ workflow: probe, qualityProfile });
}
function legacyRequirementProbe(workflow, qualityProfile) {
// Measure the complete fixed-v1 document without treating an authored
// viewBox as its intrinsic requirement. The authored viewBox remains a
// migration capacity and is preserved separately on the migrated document.
const probe = clone(workflow);
delete probe.meta.viewBox;
return compileWorkflow({ workflow: probe, qualityProfile });
}
function requiredViewBoxFrom(result) {
if (Array.isArray(result?.receipt?.requiredViewBox)) {
return [...result.receipt.requiredViewBox];
}
const required = result?.diagnostics
?.map((entry) => entry?.evidence?.requiredViewBox)
.find((candidate) => Array.isArray(candidate) && candidate.length === 2);
return required ? [...required] : null;
}
function expandableViewBox(result) {
if (result.ok || !result.diagnostics?.length) return null;
if (!result.diagnostics.every((entry) => entry.code === 'workflow/viewbox-capacity')) return null;
return requiredViewBoxFrom(result);
}
function result({
ok,
document,
fromSchemaVersion = 1,
preExistingDiagnostics = [],
migrationDiagnostics = [],
newSchemaDiagnostics = [],
changedCoordinates = [],
oldRequiredViewBox = null,
newRequiredViewBox = null,
}) {
return {
ok,
...(document ? { document } : {}),
fromSchemaVersion,
toSchemaVersion: TARGET_SCHEMA_VERSION,
preExistingDiagnostics,
migrationDiagnostics,
newSchemaDiagnostics,
changedCoordinates,
oldRequiredViewBox,
newRequiredViewBox,
};
}
export function migrateWorkflowDocument(inputWorkflow) {
if (!inputWorkflow || typeof inputWorkflow !== 'object' || Array.isArray(inputWorkflow)) {
return result({
ok: false,
migrationDiagnostics: [diagnostic({
code: 'migration/source-document',
message: 'Workflow migration requires one parsed JSON object.',
supportedFixes: ['provide one workflow schema v1 JSON document'],
})],
});
}
// Migration has no quality override: the authored policy (or effective
// standard default) must validate the document after it leaves this process.
const qualityProfile = inputWorkflow.meta?.quality_profile || 'standard';
const workflow = clone(inputWorkflow);
const preExistingDiagnostics = schemaDiagnostics(workflow);
if (preExistingDiagnostics.length) {
return result({
ok: false,
fromSchemaVersion: workflow.schema_version,
preExistingDiagnostics,
});
}
if (workflow.schema_version === TARGET_SCHEMA_VERSION) {
const compiled = compileWorkflow({ workflow: clone(workflow), qualityProfile });
const requiredViewBox = requiredViewBoxFrom(compiled);
if (!compiled.ok) {
return result({
ok: false,
fromSchemaVersion: TARGET_SCHEMA_VERSION,
preExistingDiagnostics: compiled.diagnostics,
oldRequiredViewBox: requiredViewBox,
newRequiredViewBox: requiredViewBox,
});
}
return result({
ok: true,
document: workflow,
fromSchemaVersion: TARGET_SCHEMA_VERSION,
oldRequiredViewBox: requiredViewBox,
newRequiredViewBox: requiredViewBox,
});
}
if (workflow.schema_version !== 1) {
return result({
ok: false,
fromSchemaVersion: workflow.schema_version,
migrationDiagnostics: [diagnostic({
code: 'migration/source-schema-version',
message: 'Workflow migration to schema v2 requires a schema v1 or v2 source.',
subject: { path: '/schema_version' },
evidence: { actual: workflow.schema_version, expected: [1, 2] },
supportedFixes: ['use an unchanged schema v1 workflow or an already migrated schema v2 workflow as the source'],
})],
});
}
const legacy = compileWorkflow({ workflow: clone(workflow), qualityProfile });
const legacyProbe = legacyLayoutProbe(workflow, qualityProfile);
if (!legacyProbe.ok) {
return result({
ok: false,
preExistingDiagnostics: legacy.ok ? [] : legacy.diagnostics,
migrationDiagnostics: legacyProbe.diagnostics,
});
}
const legacyRequirement = legacyRequirementProbe(workflow, qualityProfile);
const oldRequiredViewBox = requiredViewBoxFrom(legacyRequirement)
|| requiredViewBoxFrom(legacyProbe)
|| requiredViewBoxFrom(legacy);
const preExistingLayoutDiagnostics = legacy.ok ? [] : legacy.diagnostics;
let planned = compileWorkflow({ workflow: intrinsicWorkflow(workflow), qualityProfile });
if (!planned.ok) {
// Old absolute pins can be invalid at the new rank centers before their X
// coordinates are mapped. Obtain the same rank plan from an automatic-route
// projection, then validate every authored pin again after mapping.
planned = compileWorkflow({ workflow: planningWorkflow(workflow), qualityProfile });
}
if (!planned.ok) {
return result({
ok: false,
preExistingDiagnostics: preExistingLayoutDiagnostics,
newSchemaDiagnostics: planned.diagnostics,
oldRequiredViewBox,
newRequiredViewBox: requiredViewBoxFrom(planned),
});
}
let mappedCandidate;
try {
mappedCandidate = createMappedWorkflowCandidate(
workflow,
legacyProbe.receipt.columns,
planned.receipt.columns,
);
} catch (error) {
return result({
ok: false,
preExistingDiagnostics: preExistingLayoutDiagnostics,
migrationDiagnostics: [diagnostic({
code: 'migration/rank-mapping',
message: 'Could not construct a stable horizontal rank mapping.',
evidence: { reason: error.message },
supportedFixes: ['report the workflow and compiler receipts to the Archify maintainers'],
})],
oldRequiredViewBox,
newRequiredViewBox: requiredViewBoxFrom(planned),
});
}
const { document: migrated, changedCoordinates } = mappedCandidate;
let compiled = compileWorkflow({ workflow: migrated, qualityProfile });
const requiredExpansion = migrated.meta.viewBox ? expandableViewBox(compiled) : null;
if (requiredExpansion) {
const current = migrated.meta.viewBox;
const expanded = [
Math.max(current[0], requiredExpansion[0]),
Math.max(current[1], requiredExpansion[1]),
];
if (expanded[0] > current[0] || expanded[1] > current[1]) {
migrated.meta.viewBox = expanded;
compiled = compileWorkflow({ workflow: migrated, qualityProfile });
}
}
const newRequiredViewBox = requiredViewBoxFrom(compiled) || requiredViewBoxFrom(planned);
if (!compiled.ok) {
return result({
ok: false,
preExistingDiagnostics: preExistingLayoutDiagnostics,
newSchemaDiagnostics: compiled.diagnostics,
changedCoordinates,
oldRequiredViewBox,
newRequiredViewBox,
});
}
const migratedSchemaDiagnostics = schemaDiagnostics(migrated);
if (migratedSchemaDiagnostics.length) {
return result({
ok: false,
preExistingDiagnostics: preExistingLayoutDiagnostics,
newSchemaDiagnostics: migratedSchemaDiagnostics,
changedCoordinates,
oldRequiredViewBox,
newRequiredViewBox,
});
}
return result({
ok: true,
document: migrated,
preExistingDiagnostics: preExistingLayoutDiagnostics,
changedCoordinates,
oldRequiredViewBox,
newRequiredViewBox,
});
}
export function serializeMigratedWorkflow(workflow) {
return `${JSON.stringify(workflow, null, 2)}\n`;
}
+42
View File
@@ -0,0 +1,42 @@
{
"name": "archify",
"version": "3.0.1",
"private": true,
"type": "module",
"description": "JSON-IR diagram renderers (architecture / workflow / sequence / dataflow / lifecycle).",
"license": "MIT",
"bin": {
"archify": "./bin/archify.mjs"
},
"engines": {
"node": ">=18"
},
"scripts": {
"generate:viewer": "node ../scripts/generate-viewer.mjs",
"check:viewer": "node ../scripts/generate-viewer.mjs --check",
"generate:brand-marks": "node scripts/generate-brand-marks.mjs",
"check:brand-marks": "node scripts/generate-brand-marks.mjs --check",
"generate:validators": "node scripts/generate-validators.mjs",
"check:validators": "node scripts/generate-validators.mjs --check",
"check:release-identity": "node ../scripts/check-release-identity.mjs",
"build:gallery": "node ../scripts/build-gallery.mjs ../docs",
"build:guide": "node ../scripts/build-guide.mjs ../docs/guide.html",
"build:start": "node ../scripts/build-start.mjs ../docs/start.html",
"build:readme-showcase": "node ../scripts/build-readme-showcase.mjs",
"test:webm": "node test/webm-artifact.smoke.mjs && node --test test/site-language-integration.mjs",
"test:browser": "node ../scripts/run-browser-tests.mjs",
"test": "npm run check:viewer && npm run check:brand-marks && npm run check:validators && npm run check:release-identity && node test/golden.mjs && node ../scripts/run-tests.mjs",
"render:examples": "node scripts/render-examples.mjs ../examples"
},
"devDependencies": {
"ajv": "^8.17.1",
"parse5": "7.3.0",
"saxes": "6.0.0",
"simple-icons": "16.28.0",
"skills": "1.7.0",
"yaml": "2.9.1"
},
"overrides": {
"fast-uri": "^3.1.7"
}
}
+430
View File
@@ -0,0 +1,430 @@
const RAW_RECIPES = [
{
id: 'system-overview', type: 'architecture', proof: 'web-app',
presentation: { preset: 'classic', motion: 'static' },
start: {
en: { descriptionPrompt: 'Use Archify to turn this plain-language system description into a high-level architecture diagram: [describe the users, core components, primary path, external dependencies, and boundaries]. No repository is required. Ask only for missing facts that would materially change the diagram, mark any remaining unknowns instead of inventing them, and keep one obvious primary path across 8–12 core components.' },
zh: { descriptionPrompt: '用 Archify 把下面这段自然语言系统描述画成高层架构图:[在这里描述用户、核心组件、主要路径、外部依赖和边界]。不需要代码库。只追问会实质影响图的缺失信息,其余不确定内容要标明而不是编造;保留 8–12 个核心组件和一条一眼可见的主路径。' },
},
signals: [['system overview', 12], ['architecture', 10], ['components', 6], ['services', 4], ['repository', 5], ['trust boundary', 8], ['架构', 10], ['系统总览', 12], ['组件', 6], ['服务', 4], ['仓库', 5], ['信任边界', 8]],
en: {
title: 'System overview', question: 'What exists, who owns it, and how is it connected?',
summary: 'A bounded map of core components, external dependencies, primary paths, and trust boundaries.',
useWhen: 'Onboarding, design reviews, repository orientation, or explaining a service landscape.',
avoidWhen: 'The audience needs exact call order, state transitions, or row-level data lineage.',
include: ['8–12 core components', 'one primary path', 'external dependencies', 'trust boundaries'],
prompt: 'Analyze this repository, then use Archify to create a high-level architecture diagram. Show 8–12 core runtime components, one primary request or data path, external dependencies, ownership or trust boundaries, and put supporting detail in cards instead of adding more edges.',
},
zh: {
title: '系统总览', question: '系统里有什么、归谁负责、彼此如何连接?',
summary: '用一张有边界的图展示核心组件、外部依赖、主路径和信任边界。',
useWhen: '适合新人上手、方案评审、仓库梳理和服务全景说明。',
avoidWhen: '如果重点是精确调用顺序、状态流转或字段级血缘,请换其他配方。',
include: ['8–12 个核心组件', '一条主路径', '外部依赖', '归属或信任边界'],
prompt: '分析这个仓库,然后用 Archify 生成高层系统架构图。展示 8–12 个核心运行时组件、一条主要请求或数据路径、外部依赖、归属或信任边界;支持性细节放进卡片,不要继续堆连线。',
},
},
{
id: 'deployment-ownership', type: 'architecture', proof: 'deployment-ownership',
presentation: { preset: 'blueprint', motion: 'trace' },
signals: [['deployment topology', 14], ['region', 7], ['vpc', 9], ['cluster', 6], ['availability zone', 8], ['ownership', 7], ['cloud deployment', 12], ['部署拓扑', 14], ['区域', 6], ['集群', 6], ['可用区', 8], ['资源归属', 9], ['跨区', 8]],
en: {
title: 'Deployment ownership', question: 'Where does each workload run, and what crosses a boundary?',
summary: 'A deployment-focused map of regions, networks, clusters, workloads, stores, and cross-boundary mechanisms.',
useWhen: 'Cloud reviews, production readiness, multi-region planning, or infrastructure ownership handoffs.',
avoidWhen: 'Deployment facts are unknown or the real question is application behavior rather than placement.',
include: ['regions and networks', 'workload ownership', 'stateful services', 'named boundary crossings'],
prompt: 'Use Archify to draw the production deployment topology. Group resources by region, network, cluster, and owner; show workloads and stateful services; label every cross-boundary mechanism. Do not invent deployment facts—mark unknown areas explicitly. If the user wants a fail-closed deployment review, ask before setting meta.engineering_profile to deployment-ownership; otherwise leave the engineering profile unset.',
},
zh: {
title: '部署与归属', question: '每个工作负载运行在哪里,哪些连接跨越了边界?',
summary: '围绕 Region、网络、集群、工作负载、存储和跨边界机制组织部署图。',
useWhen: '适合云上评审、生产就绪、多区域规划和基础设施交接。',
avoidWhen: '部署事实不清楚,或真正问题是应用行为而不是资源位置时不要使用。',
include: ['区域与网络', '工作负载归属', '有状态服务', '明确的跨边界机制'],
prompt: '用 Archify 绘制生产部署拓扑。按区域、网络、集群和负责人分组,展示工作负载与有状态服务,并标注每一种跨边界机制。不要编造部署事实,不确定的区域要明确标出。如果用户需要失败即阻断的部署评审,先征得确认,再把 meta.engineering_profile 设为 deployment-ownership;否则不要启用工程画像。',
},
},
{
id: 'agent-tool-call', type: 'workflow', proof: 'agent-tool-call',
presentation: { preset: 'signal-flow', motion: 'trace' },
start: {
en: { descriptionPrompt: 'Use Archify workflow mode to turn this description into a diagram: [paste the actors, main steps, decisions, approvals, and exception paths]. Use lanes for distinct owners, keep one unmistakable happy path, and mark missing ownership or unresolved branches instead of inventing them.' },
zh: { descriptionPrompt: '用 Archify 工作流模式把下面的描述画成图:[粘贴参与者、主要步骤、决策、审批和异常路径]。不同负责方使用独立泳道,保留一条明确的成功主路径,缺失的负责人或未定分支要标明而不是编造。' },
},
signals: [['agent tool call', 16], ['tool call', 12], ['approval gate', 10], ['human in the loop', 9], ['mcp', 7], ['planner', 6], ['agent loop', 10], ['智能体工具调用', 16], ['工具调用', 12], ['审批门', 10], ['人在回路', 9], ['规划器', 6], ['智能体循环', 10]],
en: {
title: 'Agent tool-call loop', question: 'How does an agent plan, get permission, act, recover, and report?',
summary: 'A lane-based agent loop with policy gates, tool execution, exception recovery, evidence, and final response.',
useWhen: 'Explaining agent runtimes, MCP/tool orchestration, approvals, retries, or observability.',
avoidWhen: 'The goal is only to show static agent components or exact API message timing.',
include: ['request and planning', 'policy or approval gate', 'tool execution', 'exception and evidence paths'],
prompt: 'Use Archify workflow mode to explain this agent tool-call loop. Separate user surface, agent runtime, policy boundary, exception handling, tool execution, and observability into lanes. Make the successful path primary and show approval, retry, blocked, and evidence paths explicitly.',
},
zh: {
title: '智能体工具调用', question: '智能体如何规划、获批、执行、恢复并汇报?',
summary: '用泳道表达策略门、工具执行、异常恢复、证据和最终回复。',
useWhen: '适合解释 Agent Runtime、MCP/工具编排、审批、重试和可观测性。',
avoidWhen: '如果只想看静态组件,或重点是精确 API 消息时序,请换其他配方。',
include: ['请求与规划', '策略或审批门', '工具执行', '异常与证据路径'],
prompt: '用 Archify 工作流模式解释这段智能体工具调用。把用户界面、Agent Runtime、策略边界、异常处理、工具执行和可观测性分成泳道;突出成功主路径,并明确展示审批、重试、阻塞和证据路径。',
},
},
{
id: 'delivery-workflow', type: 'workflow', proof: 'delivery-workflow',
presentation: { preset: 'classic', motion: 'trace' },
signals: [['ci/cd', 14], ['release workflow', 14], ['deployment pipeline', 11], ['pull request', 7], ['staging', 7], ['rollback', 8], ['发布流程', 14], ['流水线', 9], ['上线', 7], ['预发', 7], ['回滚', 8], ['审批发布', 10]],
en: {
title: 'Delivery workflow', question: 'How does a change move safely from commit to production?',
summary: 'A delivery flow with build, checks, environments, approvals, smoke tests, rollback, and ownership lanes.',
useWhen: 'CI/CD design, release reviews, deployment governance, or onboarding developers to delivery.',
avoidWhen: 'The question is where infrastructure runs or what states a deployment object can occupy.',
include: ['trigger and build', 'blocking checks', 'approval and environments', 'rollback and verification'],
prompt: 'Use Archify workflow mode to draw this delivery process from commit to production. Separate developer, CI, approval, environment, and exception lanes; mark blocking checks, smoke tests, ownership, and the rollback path. Keep one unmistakable happy path.',
},
zh: {
title: '研发交付流程', question: '一次变更如何安全地从提交走到生产?',
summary: '展示构建、检查、环境、审批、冒烟、回滚和负责人泳道。',
useWhen: '适合 CI/CD 设计、发布评审、部署治理和研发新人上手。',
avoidWhen: '如果重点是基础设施位置或部署对象的状态集合,请换架构图或生命周期图。',
include: ['触发与构建', '阻断检查', '审批与环境', '回滚与验证'],
prompt: '用 Archify 工作流模式绘制从代码提交到生产发布的流程。拆分开发者、CI、审批、环境和异常泳道;标出阻断检查、冒烟测试、负责人和回滚路径,并保留一条一眼可见的成功主路径。',
},
},
{
id: 'incident-runbook', type: 'workflow', proof: 'incident-runbook',
presentation: { preset: 'signal-flow', motion: 'trace' },
signals: [['incident response', 15], ['runbook', 12], ['outage', 9], ['triage', 8], ['mitigation', 8], ['escalation', 7], ['事故处置', 15], ['故障', 9], ['应急预案', 12], ['排障', 9], ['缓解', 7], ['升级响应', 8]],
en: {
title: 'Incident runbook', question: 'How do responders detect, triage, mitigate, verify, and escalate?',
summary: 'An operational workflow that separates signals, responders, mitigation, communications, and recovery proof.',
useWhen: 'Incident playbooks, on-call handoffs, reliability reviews, and tabletop exercises.',
avoidWhen: 'The audience needs live metrics or a post-incident component topology instead of response actions.',
include: ['detection signal', 'triage owner', 'mitigation and rollback', 'verification and communication'],
prompt: 'Use Archify workflow mode to turn this incident runbook into responder lanes. Show detection, triage, mitigation, escalation, communication, rollback, and recovery verification. Separate decision gates from actions and make missing ownership visible.',
},
zh: {
title: '事故处置 Runbook', question: '响应者如何发现、分诊、缓解、验证并升级?',
summary: '把信号、响应者、缓解动作、沟通和恢复证据拆成可执行流程。',
useWhen: '适合故障预案、On-call 交接、稳定性评审和桌面演练。',
avoidWhen: '如果受众需要实时指标仪表盘或事故后的组件拓扑,而不是响应动作,请换其他视图。',
include: ['发现信号', '分诊负责人', '缓解与回滚', '恢复验证与沟通'],
prompt: '用 Archify 工作流模式把事故处置预案画成响应者泳道。展示发现、分诊、缓解、升级、沟通、回滚和恢复验证;把决策门与操作分开,并让缺失的负责人清晰可见。',
},
},
{
id: 'api-request', type: 'sequence', proof: 'cache-miss',
presentation: { preset: 'classic', motion: 'trace' },
start: {
en: { descriptionPrompt: 'Use Archify sequence mode to draw this interaction: [paste the participants, calls, returns, fallback, and asynchronous side effects]. Keep message order unambiguous, labels short, and unknown behavior explicit. No repository is required.' },
zh: { descriptionPrompt: '用 Archify 时序模式绘制下面的交互:[粘贴参与者、调用、返回、回退和异步副作用]。确保消息顺序无歧义、标签简短,并明确标注未知行为。不需要代码库。' },
},
signals: [['api request', 14], ['request response', 12], ['call chain', 11], ['cache miss', 13], ['jwt', 8], ['who calls whom', 12], ['api 请求', 14], ['请求响应', 12], ['调用链', 11], ['缓存未命中', 13], ['谁调用谁', 12], ['鉴权链路', 9]],
en: {
title: 'API request chain', question: 'Who calls whom, in what order, and what returns?',
summary: 'A time-ordered request path with authentication, cache fallback, persistence, return traffic, and async trace.',
useWhen: 'API documentation, debugging request latency, auth reviews, or explaining cache fallback.',
avoidWhen: 'Order is unimportant and the audience only needs the stable service topology.',
include: ['callers and callees', 'request and return messages', 'fallback or error path', 'async side effects'],
prompt: 'Use Archify sequence mode to show this request from caller to final response. Include authentication, cache hit or miss, persistence fallback, return messages, and asynchronous trace or event emission. Keep message labels short and order unambiguous.',
},
zh: {
title: 'API 请求链', question: '谁调用谁、顺序如何、最终返回什么?',
summary: '按时间展示鉴权、缓存回退、持久化、返回流量和异步追踪。',
useWhen: '适合 API 文档、请求耗时排查、鉴权评审和缓存回退说明。',
avoidWhen: '如果顺序不重要,受众只需要稳定的服务拓扑,请用架构图。',
include: ['调用方与被调用方', '请求与返回消息', '回退或错误路径', '异步副作用'],
prompt: '用 Archify 时序模式展示从调用方到最终响应的完整请求。包含鉴权、缓存命中或未命中、持久化回退、返回消息,以及异步 Trace 或事件上报;消息标签保持简短,顺序必须明确。',
},
},
{
id: 'async-roundtrip', type: 'sequence', proof: 'async-roundtrip',
presentation: { preset: 'signal-flow', motion: 'trace' },
signals: [['async roundtrip', 14], ['webhook', 10], ['callback', 10], ['acknowledgement', 8], ['timeout', 7], ['retry message', 8], ['异步回调', 14], ['回调', 10], ['确认消息', 8], ['超时', 7], ['消息重试', 9], ['webhook', 10]],
en: {
title: 'Async roundtrip', question: 'What happens after the initial request returns?',
summary: 'A sequence view of enqueue, acknowledgement, background work, callbacks, retries, timeout, and final consistency.',
useWhen: 'Webhooks, jobs, queues, payment callbacks, eventual consistency, or async API contracts.',
avoidWhen: 'The primary question is topic topology and consumer ownership rather than time order.',
include: ['initial acknowledgement', 'queue or scheduler', 'background work', 'callback, retry, and timeout'],
prompt: 'Use Archify sequence mode to explain this asynchronous roundtrip. Show the initial acknowledgement, enqueue or scheduling step, background processing, callback or polling, retry and timeout behavior, and the point where the caller can observe final consistency.',
},
zh: {
title: '异步往返链路', question: '初始请求返回之后,后台还会发生什么?',
summary: '按时间展示入队、确认、后台处理、回调、重试、超时和最终一致。',
useWhen: '适合 Webhook、后台任务、队列、支付回调、最终一致和异步 API 契约。',
avoidWhen: '如果重点是 Topic 拓扑和消费者归属,而不是时间顺序,请用事件数据流配方。',
include: ['初始确认', '队列或调度器', '后台处理', '回调、重试与超时'],
prompt: '用 Archify 时序模式解释这段异步往返链路。展示初始确认、入队或调度、后台处理、回调或轮询、重试与超时,以及调用方何时能观察到最终一致结果。',
},
},
{
id: 'data-lineage', type: 'dataflow', proof: 'product-analytics',
presentation: { preset: 'classic', motion: 'trace' },
signals: [['data lineage', 15], ['etl', 12], ['warehouse', 9], ['pii', 11], ['governance', 9], ['analytics pipeline', 12], ['数据血缘', 15], ['数据管道', 11], ['数仓', 9], ['治理', 9], ['隐私数据', 10], ['用户同意', 9]],
en: {
title: 'Data lineage', question: 'Where does data come from, how does it change, and who consumes it?',
summary: 'A governed path from sources through consent, transforms, sensitive stores, warehouse, and consumers.',
useWhen: 'Analytics architecture, ETL/ELT review, PII assessment, warehouse design, or model feature lineage.',
avoidWhen: 'The audience needs request timing or operational task ownership rather than data assets.',
include: ['sources and assets', 'transform stages', 'classification or consent', 'stores and consumers'],
prompt: 'Use Archify dataflow mode to map this data lineage. Name every data asset and transform, show consent or classification boundaries, distinguish streaming from batch paths, and identify stores plus downstream consumers. Do not use unlabeled flows.',
},
zh: {
title: '数据血缘', question: '数据从哪里来、如何变化、最终被谁消费?',
summary: '从来源经过同意、转换、敏感存储、数仓直到消费者的治理路径。',
useWhen: '适合分析架构、ETL/ELT 评审、PII 评估、数仓设计和特征血缘。',
avoidWhen: '如果受众需要请求时序或操作负责人,而不是数据资产,请换其他配方。',
include: ['数据来源与资产', '转换阶段', '分类或同意边界', '存储与消费者'],
prompt: '用 Archify 数据流模式梳理这段数据血缘。为每个数据资产和转换命名,展示用户同意或数据分类边界,区分流式与批处理路径,并标明存储和下游消费者;所有数据流都必须有标签。',
},
},
{
id: 'event-stream', type: 'dataflow', proof: 'event-stream',
presentation: { preset: 'signal-flow', motion: 'trace' },
start: {
en: { descriptionPrompt: 'Use Archify dataflow mode to map this data journey: [paste the sources, data assets, transforms, stores, boundaries, and consumers]. Label every flow, distinguish streaming from batch where relevant, and mark unknown classifications or ownership instead of inventing them.' },
zh: { descriptionPrompt: '用 Archify 数据流模式梳理下面的数据路径:[粘贴来源、数据资产、转换、存储、边界和消费者]。为每条数据流标注名称,在有意义时区分流式与批处理,未知的分类或归属要标明而不是编造。' },
},
signals: [['event stream', 15], ['kafka topology', 14], ['topic', 8], ['consumer group', 11], ['dead letter', 10], ['dlq', 10], ['事件流', 15], ['kafka 拓扑', 14], ['主题', 7], ['消费者组', 11], ['死信', 10], ['事件地铁图', 12]],
en: {
title: 'Event-stream topology', question: 'Which events move through which topics, processors, groups, and failure paths?',
summary: 'A stream map of producers, topics, ordered processors, consumer groups, state, replay, and DLQ.',
useWhen: 'Kafka/event-platform design, stream processing reviews, ownership, replay, and failure handling.',
avoidWhen: 'Topic names, consumer groups, and delivery semantics are not known—use a generic workflow instead.',
include: ['producers and event names', 'topics and ordering', 'processors and consumer groups', 'state, replay, and DLQ'],
prompt: 'Use Archify dataflow mode to draw this event-stream topology. Name producers, events, topics, ordered processors, consumer groups, state stores, replay paths, and the DLQ. Show ownership and delivery semantics only when supported by evidence.',
},
zh: {
title: '事件流拓扑', question: '哪些事件经过哪些 Topic、处理器、消费者组和失败路径?',
summary: '展示生产者、Topic、有序处理器、消费者组、状态、重放和 DLQ。',
useWhen: '适合 Kafka/事件平台设计、流处理评审、归属、重放和失败处理。',
avoidWhen: '如果 Topic、消费者组和投递语义都不清楚,请先用通用工作流,不要编造事件拓扑。',
include: ['生产者与事件名', 'Topic 与顺序', '处理器与消费者组', '状态、重放与 DLQ'],
prompt: '用 Archify 数据流模式绘制这段事件流拓扑。命名生产者、事件、Topic、有序处理器、消费者组、状态存储、重放路径和 DLQ;只有在证据充分时才标注归属和投递语义。',
},
},
{
id: 'object-lifecycle', type: 'lifecycle', proof: 'agent-run',
presentation: { preset: 'classic', motion: 'trace' },
start: {
en: { descriptionPrompt: 'Use Archify lifecycle mode to model this object: [paste its states, transition events, waits, retries, cancellation, and terminal outcomes]. Separate active, waiting, recoverable-failure, and terminal states, and never hide an ending. No repository is required.' },
zh: { descriptionPrompt: '用 Archify 生命周期模式建模这个对象:[粘贴它的状态、转换事件、等待、重试、取消和终态]。分开执行、等待、可恢复失败和终态,不要隐藏任何结束方式。不需要代码库。' },
},
signals: [['state machine', 15], ['object lifecycle', 14], ['status transition', 11], ['terminal state', 9], ['retry state', 8], ['状态机', 15], ['生命周期', 13], ['状态流转', 11], ['终态', 9], ['等待态', 8], ['重试状态', 8]],
en: {
title: 'Object lifecycle', question: 'Which states exist, what events move between them, and how does it end?',
summary: 'A state model with active work, waits, retries, cancellation, failure, and explicit terminal outcomes.',
useWhen: 'Tasks, orders, tickets, subscriptions, jobs, agent runs, or any durable object with status.',
avoidWhen: 'The object has no durable state and the real question is participant interaction over time.',
include: ['start and active states', 'event-labelled transitions', 'wait and retry states', 'all terminal outcomes'],
prompt: 'Use Archify lifecycle mode to model this object. Separate main progress, waiting or interruption states, and terminal outcomes. Label transitions with events, include retry, cancellation, timeout, success, and failure where real, and never hide an ending.',
},
zh: {
title: '对象生命周期', question: '有哪些状态、什么事件触发流转、最终如何结束?',
summary: '展示执行、等待、重试、取消、失败以及明确终态的状态模型。',
useWhen: '适合任务、订单、工单、订阅、作业、Agent Run 等带持久状态的对象。',
avoidWhen: '对象没有持久状态,真正问题是参与者随时间的交互时,请使用时序图。',
include: ['开始与执行态', '带事件的转换', '等待与重试态', '所有终态'],
prompt: '用 Archify 生命周期模式建模这个对象。分开主进度、等待或中断状态和终态;用事件标注转换,并在真实存在时展示重试、取消、超时、成功和失败,不能隐藏任何结束方式。',
},
},
{
id: 'deployment-lifecycle', type: 'lifecycle', proof: 'deployment-lifecycle',
presentation: { preset: 'signal-flow', motion: 'trace' },
signals: [['deployment lifecycle', 15], ['release state', 10], ['promotion state', 9], ['approval status', 8], ['rollback state', 10], ['部署生命周期', 15], ['发布状态', 10], ['晋级', 7], ['审批状态', 8], ['回滚状态', 10]],
en: {
title: 'Deployment lifecycle', question: 'What state is a release in, and what can happen next?',
summary: 'A deployment state model covering queued, building, verifying, approval, promotion, rollback, and terminal outcomes.',
useWhen: 'Release controllers, GitOps reconciliation, environment promotion, or deployment status APIs.',
avoidWhen: 'The question is the human/CI sequence of delivery actions rather than the deployment object state.',
include: ['queued and running states', 'verification and approval', 'promotion and rollback', 'success, failure, cancellation'],
prompt: 'Use Archify lifecycle mode to model the deployment object. Show queued, building, verifying, waiting for approval, promoting, rolling back, and every terminal outcome. Label the events and guards that permit each transition.',
},
zh: {
title: '部署生命周期', question: '一次发布当前处于什么状态,下一步可能发生什么?',
summary: '覆盖排队、构建、验证、审批、晋级、回滚和终态的部署状态模型。',
useWhen: '适合发布控制器、GitOps 对账、环境晋级和部署状态 API。',
avoidWhen: '如果重点是人员与 CI 的交付动作顺序,而不是部署对象状态,请用交付工作流。',
include: ['排队与执行态', '验证与审批', '晋级与回滚', '成功、失败与取消'],
prompt: '用 Archify 生命周期模式建模部署对象。展示排队、构建、验证、等待审批、晋级、回滚以及所有终态,并标注允许每次状态转换的事件和守卫条件。',
},
},
{
id: 'layout-repair', type: 'architecture', proof: 'web-app',
presentation: { preset: 'classic', motion: 'static' },
signals: [
['layout repair', 20], ['repair order', 20], ['fix order', 20],
['viewport overflow', 20], ['overflow', 16], ['scrollheight', 16], ['scrollwidth', 16],
['overlap', 16], ['label overlap', 20], ['edge through node', 20], ['crossing', 8],
['via', 7], ['waypoint', 14],
['布局修复', 20], ['修复顺序', 20], ['视口溢出', 20], ['溢出', 16], ['滚动高度', 16], ['滚动宽度', 16],
['重叠', 16], ['标签重叠', 20], ['连线穿过节点', 20], ['连线穿节点', 20], ['交叉', 8],
['途经点', 14], ['路径点', 14], ['拐点', 14],
],
en: {
title: 'Layout repair', question: 'Why does the existing diagram overflow, overlap, or route through nodes, and what should be fixed first?',
summary: 'Repair an existing diagram in its current mode using validation diagnostics, explicit waypoint semantics, and a measured desktop viewport budget.',
useWhen: 'An existing architecture, workflow, sequence, dataflow, or lifecycle diagram needs layout repair; keep its diagram type and presentation settings.',
avoidWhen: 'The task is choosing a new diagram type. Do not change topology, delete meaningful labels, or hide overflow just to pass checks.',
include: [
'repair order: schema → overlap → direction → crossings → labels',
'via contract: absolute [x, y] intermediate points; route = [start, ...via, end]',
'desktop viewport budget for the complete page, including header and necessary cards',
'validate after each edit, then inspect the final HTML in a browser',
],
prompt: 'Use Archify to repair this existing diagram while preserving its diagram type, topology, meaningful labels, and presentation settings. Follow references/authoring-contract.md in this order: (1) schema and missing/invalid meta.quality_profile; (2) node overlap or out-of-range placement; (3) edge-through-node and endpoint-direction errors; (4) crossings, ambiguous corridors, border runs, excessive detours, and route rhythm; (5) labels: label-to-node, label-to-label, then label-to-route clearance. Run validate after every edit and use diagnostics[] code, subject, evidence, and supportedFixes; apply one diagnosed geometry control at a time. Where the current schema supports via, it is an ordered array of absolute SVG [x, y] intermediate points: the route is [start, ...via, end], with start/end supplied by the node anchors. Explicit via points override automatic routing; they are not offsets or a request for automatic obstacle avoidance. For an orthogonal repair, align adjacent points on the same x or y and make the first/final segment respect fromSide/toSide; use only controls supported by the current diagram mode. Follow references/delivery-contract.md for the viewport budget: check 1440×900, 1600×1000, 1920×1080, and 2048×1320; require document.documentElement.scrollWidth <= window.innerWidth. Prefer document.documentElement.scrollHeight <= window.innerHeight, but preserve a Reader-declared readable vertical page scroll when browser-check explicitly accepts it after reaching the projected-text floor. Budget the entire page, including header, diagram, and necessary cards. Repair every other overflow by removing only redundant content or compacting spacing first; do not hide overflow, clip content, introduce an internal diagram scroller, stretch the SVG, or shrink typography to force a pass. Let finalize run the required browser evidence; run perceptual visual review only when requested or escalated by the delivery contract.',
},
zh: {
title: '布局修复', question: '现有图为什么仍然溢出、重叠或连线穿过节点,应该先修什么?',
summary: '保留现有图表模式,依据验证诊断、途经点语义和实测桌面视口预算修复布局。',
useWhen: '已有架构图、工作流、时序图、数据流或生命周期图需要修复布局;保留原来的图表类型和表现设置。',
avoidWhen: '任务是为新图选择类型时不要使用。不要为了通过检查改变拓扑、删除有意义的标签或隐藏溢出。',
include: [
'修复顺序:schema → 重叠 → 方向 → 交叉 → 标签',
'via 契约:绝对 [x, y] 中间点;路径 = [start, ...via, end]',
'包含标题和必要卡片的整页桌面视口预算',
'每次修改后 validate,最终在浏览器中检查 HTML',
],
prompt: '用 Archify 修复这张现有图,保留图表类型、拓扑、有意义的标签和表现设置。遵循 references/authoring-contract.md 的顺序:(1) schema 错误及缺失或无效的 meta.quality_profile;(2) 节点重叠或越界;(3) 连线穿过节点及端点方向错误;(4) 交叉、含混的共享通道、贴边走线、过度绕行和转弯节奏;(5) 标签与节点、标签与标签、标签与连线的间距。每次修改后运行 validate,依据 diagnostics[] 的 code、subject、evidence 和 supportedFixes,每次只应用一项有诊断依据的几何控制。当前 schema 支持 via 时,它是按顺序排列的绝对 SVG [x, y] 中间点数组:路径为 [start, ...via, end],起终点由节点锚点提供。显式 via 会覆盖自动路由,不是偏移量,也不会请求自动绕障。修复正交走线时,相邻点应共享 x 或 y,首尾线段应遵守 fromSide/toSide;只使用当前模式支持的控制字段。视口预算遵循 references/delivery-contract.md:检查 1440×900、1600×1000、1920×1080 和 2048×1320,要求 document.documentElement.scrollWidth <= window.innerWidth。优先满足 document.documentElement.scrollHeight <= window.innerHeight;如果 browser-check 明确确认 Reader 已达到投影文字下限并接受可读的页面纵向滚动,则保留该滚动。预算覆盖整页,包括标题、主图和必要卡片。其他溢出先通过移除冗余内容或压缩间距修复,不得靠隐藏溢出、裁切、内部滚动区、拉伸 SVG 或缩小字体强行通过。让 finalize 执行必需的浏览器证据;只在用户要求或 delivery contract 规定的升级条件下进行感知视觉审阅。',
},
},
];
export const SCENARIO_RECIPES = Object.freeze(RAW_RECIPES.map((recipe) => Object.freeze({
...recipe,
presentation: Object.freeze({ ...recipe.presentation }),
...(recipe.start ? { start: Object.freeze({
en: Object.freeze({ ...recipe.start.en }),
zh: Object.freeze({ ...recipe.start.zh }),
}) } : {}),
signals: Object.freeze(recipe.signals.map((signal) => Object.freeze(signal.slice()))),
en: Object.freeze({ ...recipe.en, include: Object.freeze(recipe.en.include.slice()) }),
zh: Object.freeze({ ...recipe.zh, include: Object.freeze(recipe.zh.include.slice()) }),
})));
export function detectGuideLanguage(value = '') {
return /[\u3400-\u9fff]/u.test(value) ? 'zh' : 'en';
}
export function startPromptsFor(recipe, lang = 'en') {
const language = lang === 'zh' ? 'zh' : 'en';
const copy = recipe[language];
const descriptionPrompt = recipe.start?.[language]?.descriptionPrompt;
if (!descriptionPrompt) {
throw new Error(`Scenario recipe ${JSON.stringify(recipe.id)} does not define a ${language} start prompt.`);
}
const repositoryPrompt = recipe.type === 'architecture'
? copy.prompt
: language === 'zh'
? `先检查这个仓库里的相关证据,然后${copy.prompt}不要编造代码无法支持的行为。`
: `Inspect this repository for evidence, then ${copy.prompt.charAt(0).toLowerCase()}${copy.prompt.slice(1)} Do not invent behavior that the code does not support.`;
return { descriptionPrompt, repositoryPrompt };
}
function normalized(value) {
return String(value || '').normalize('NFKC').toLowerCase().replace(/[\s_]+/g, ' ').trim();
}
function localized(recipe, lang) {
const copy = recipe[lang === 'zh' ? 'zh' : 'en'];
return {
id: recipe.id,
type: recipe.type,
proof: recipe.proof,
presentation: { ...recipe.presentation },
...copy,
include: copy.include.slice(),
};
}
export function listScenarioRecipes(lang = 'en') {
return SCENARIO_RECIPES.map((recipe) => localized(recipe, lang));
}
function scoreRecipe(recipe, query) {
const text = normalized(query);
if (!text) return { recipe, score: 0, matched: [] };
if (text === recipe.id || text === recipe.id.replace(/-/g, ' ')) {
return { recipe, score: 100, matched: [recipe.id] };
}
let score = 0;
const matched = [];
for (const [signal, weight] of recipe.signals) {
if (text.includes(normalized(signal))) {
score += weight;
matched.push(signal);
}
}
return { recipe, score, matched };
}
export function recommendScenario(query, options = {}) {
const lang = options.lang === 'zh' || options.lang === 'en' ? options.lang : detectGuideLanguage(query);
const ranked = SCENARIO_RECIPES.map((recipe) => scoreRecipe(recipe, query))
.sort((left, right) => right.score - left.score || SCENARIO_RECIPES.indexOf(left.recipe) - SCENARIO_RECIPES.indexOf(right.recipe));
const winner = ranked[0].score > 0 ? ranked[0] : { recipe: SCENARIO_RECIPES[0], score: 0, matched: [] };
const confidence = winner.score >= 14 ? 'high' : winner.score >= 7 ? 'medium' : 'low';
return {
ok: true,
mode: 'recommendation',
lang,
query: String(query || ''),
confidence,
matchedSignals: winner.matched.slice(),
recommendation: localized(winner.recipe, lang),
alternatives: ranked.filter((entry) => entry.recipe.id !== winner.recipe.id && entry.score > 0)
.slice(0, 2)
.map((entry) => ({ ...localized(entry.recipe, lang), score: entry.score })),
};
}
export function formatScenarioList(lang = 'en') {
const isZh = lang === 'zh';
const heading = isZh
? `Archify 场景配方(${SCENARIO_RECIPES.length})`
: `Archify scenario recipes (${SCENARIO_RECIPES.length})`;
const intro = isZh
? '先选择你要回答的问题,再选择图表类型。可运行:archify guide "你的场景"'
: 'Choose the question before the diagram type. Run: archify guide "your scenario"';
return [heading, '', intro, '', ...listScenarioRecipes(lang).flatMap((recipe) => [
`${recipe.id} [${recipe.type}] ${recipe.title}`,
` ${recipe.question}`,
])].join('\n');
}
export function formatScenarioRecommendation(result) {
const isZh = result.lang === 'zh';
const recipe = result.recommendation;
const labels = isZh ? {
heading: '推荐', question: '要回答的问题', use: '适合', avoid: '不要这样用', include: '必须包含', presentation: '表现建议', prompt: '可直接复制的提示词', alternatives: '其他可能', confidence: '置信度',
} : {
heading: 'Recommendation', question: 'Question answered', use: 'Use when', avoid: 'Avoid when', include: 'Must include', presentation: 'Presentation', prompt: 'Copy-ready prompt', alternatives: 'Other possible fits', confidence: 'Confidence',
};
const lines = [
`${labels.heading}: ${recipe.title} [${recipe.type}]`,
`${labels.confidence}: ${result.confidence}`,
`${labels.question}: ${recipe.question}`,
'',
`${labels.use}: ${recipe.useWhen}`,
`${labels.avoid}: ${recipe.avoidWhen}`,
`${labels.include}: ${recipe.include.join(isZh ? '、' : '; ')}`,
`${labels.presentation}: ${recipe.presentation.preset} · ${recipe.presentation.motion}`,
'',
`${labels.prompt}:`,
recipe.prompt,
];
if (result.alternatives.length) {
lines.push('', `${labels.alternatives}: ${result.alternatives.map((item) => `${item.title} [${item.type}]`).join(' · ')}`);
}
return lines.join('\n');
}
export function publicGuideData() {
return SCENARIO_RECIPES.map((recipe) => ({
...localized(recipe, 'en'),
en: recipe.en,
zh: recipe.zh,
signals: recipe.signals.map(([signal, weight]) => [signal, weight]),
}));
}
@@ -0,0 +1,18 @@
# Architecture layout repair
Use this after actual visual review finds several tangled routes. A successful machine receipt does not settle composition. Work on the existing candidate, retaining all required components, relationships, labels, evidence, boundaries, and node sizes.
## Choose the repair scope
If the main and secondary chains already read clearly, repair the isolated defect locally. If a main chain is blocked, several routes tangle, or a local fix moves the defect onto another route, reflow the connected scene in one edit. Preserve all semantics and user-fixed geometry; agent-generated positions and route controls may change. After moving nodes, remove stale generated route overrides so automatic routing can use the new placement.
The receipt's `directCorridorBlockers`, when present, names nodes between an edge's aligned endpoints. This is geometric evidence, not proof that the edge is the main path or a new validation failure. Trace the reader's actual main path first. When a listed blocker interrupts that path, reposition the connected group instead of adding another detour.
## One coherent repair
1. Trace the affected relationships and their endpoints in the JSON. Before choosing coordinates, write the reader’s main path as an ordered list of existing edges, then identify shared state and real feedback cycles. Every neighboring pair in that main path must have the relationship being explained; place other components on branches beside their actual owner. Use `validate architecture <candidate.json> --layout-json --repo-root <root>` once if the compact receipt and screenshot do not reveal the needed route or label geometry; omit `--repo-root` only for a design without repository evidence. Do not guess repeated waypoint coordinates.
2. Place those connected main-path neighbors in reading order. Put shared state between its readers/writers, on an adjacent row if necessary, so one writer does not need a line across the whole execution area. Arrange a feedback cycle in its actual edge order around an open rectangle, with other consumers beside their owner. For example, if the edges are `client → API → dispatcher → worker → collector → runner → dispatcher`, put the first four on the upper row, collector below worker, and runner below dispatcher: the return then uses the lower row and a short upward edge. This illustrates adjacency, not a graph or set of coordinates to copy; use only relationships present in the candidate. Preserve every edge and its actual direction.
3. Keep automatic endpoints for the new placement. Constrain a side only when a branch or return needs a specific corridor; check that corridor against every affected relationship, including storage branches. If that local constraint introduces another conflict, return to the connected placement instead of cycling through side combinations. Only use detailed `via` or label coordinates for a remaining measured defect. Keep external actors outside the resolved boundary rectangle, including its padding; not listing a node in `wraps` does not visually exclude it. Keep an internal relationship and its label inside the shared boundary unless crossing it conveys a real fact; do not imply an external hop merely to avoid another route.
4. When `composition/label-gap` reports a measured minimum, enlarge that clear gap or move the connected group onto another readable row. Keep the full label beside its own route; placing it in distant empty space does not repair the relationship. Compact unused gaps while preserving measured label space and the previous text size. Keep the main interaction and all required nodes readable in the default desktop view; do not trade crossings for a large blank canvas, tiny text, or a chain that doubles back without a semantic reason.
Run the complete `finalize` once after the edit, then `visual-check` on the successful artifact and inspect its desktop captures. When replacing an already reviewed artifact, use one fresh `--out-dir` for both commands as described in [the delivery contract](delivery-contract.md#a-new-candidate-at-an-existing-output-path). Trace the main path, each secondary chain, and every affected arrow and label. A bounded second repair may address a remaining specific defect. If it still fails visual acceptance, retain the candidate and report the concrete gap; do not count it as a successful repair or continue blind coordinate changes.
+419
View File
@@ -0,0 +1,419 @@
# Authoring contract
Read this reference only after the Fast authoring path calls for more detail. The schemas and examples remain authoritative.
## Composition repair
When correcting an authored overview's abstraction, map every affected role, relationship direction, protocol, boundary, condition, and source reference to its surviving node or relationship before regrouping. A startup citation does not prove a message protocol. Preserve each claim's inspected evidence. Keep user-supplied or agreed topology fixed; fewer routes alone do not justify merging. A boundary around one node requires an explicit isolation fact and must not merely repeat its label.
## Label repair
When a relationship label collides, move the label, adjust the route or spacing, then shorten the wording while preserving meaning. Omit wording only when both endpoints fully imply it and it conveys no protocol, action, direction, synchronous or asynchronous behavior, or cross-boundary mechanism. Spacing means clear gap rather than center distance; measured mask width takes precedence. The first-draft gap budget is in [Layout and routing](authoring-defaults.md#layout-and-routing).
For a disproportionate sublabel, keep its exact role or protocol concise and place the supplementary fact in a note or card. Preserve every required responsibility, protocol, and boundary fact. Use the first-draft node-width budget in [Layout and routing](authoring-defaults.md#layout-and-routing).
## Schema lookup
Read both the mode schema and `schemas/common.schema.json`. The mode schemas use `$ref`, so the common file is where shared enums live.
- `componentType`: `frontend`, `backend`, `database`, `cloud`, `security`, `messagebus`, `external`
- `variant`: `default`, `emphasis`, `security`, `dashed`
- Relationship IDs use the shared identifier pattern and must be unique in their collection.
Do not invent fields. Before writing any new field, enum, or constrained text, read its schema definition, including common `$ref` targets. In particular, check boundary kinds, repository identity, and source-reference shapes. An example demonstrates structure; it does not enumerate every valid value. Author fresh IDs, wording, facts, and layout.
## Workflow layout contracts
Use schema v2 for new workflows and keep schema v1 when an existing source must
retain fixed geometry. In both versions, `col` stays in `0..5` and semantic
edge labels are never deleted as a spacing repair. Do not change only
`schema_version` when absolute coordinates exist: follow the canonical
[migration and layout-receipt contract](../renderers/workflow/README.md#migration-and-layout-receipt).
The complete normative invariants live in the workflow renderer's
[layout contracts](../renderers/workflow/README.md#layout-contracts).
For sequential stages stacked in one container, use one v2 lane and group,
omit `meta.viewBox`, and center nodes around the lane content with symmetric
`yOffset` values such as `-90 / 0 / 90`. Keep semantic edge labels and act on
compiler diagnostics.
## Legend contract
Omit `meta.legend` for the truthful default: `auto` lists only semantic kinds
present in typed IR. Use `mode: "all"` for a renderer reference or
`mode: "hidden"` to remove the full legend. Under `entries`, only keys listed
by the selected mode schema are valid; each key accepts `label`, `visible`, or
both. `visible: true` may show an unused supported convention, while
`visible: false` hides it. `hidden` cannot be overridden.
A label override changes reader wording only. Never infer a kind from prose or
use the legend to compensate for missing nodes, states, messages, or flows.
Long labels are measured and wrap into deterministic rows. Architecture's
implicit automatic viewBox grows from that same measured footprint. For
backwards compatibility, a legacy document with no `meta.legend` may omit an
implicit auto legend that cannot fit its explicit viewBox; this never changes
its typed topology. Adding `meta.legend` makes the presentation intentional and
strict: if its resolved labels cannot fit the authored viewBox, shorten or hide
them, or widen the viewBox using the emitted diagnostic.
## Language consistency
Choose one primary authored language. An explicit user choice wins; otherwise
use the language of the request, or the conversation's dominant language when
the request itself is language-neutral. Separately choose the Viewer locale.
Always write the matching `meta.locale` as a well-formed language tag: `"en"`
for English, `"zh-CN"` for Simplified Chinese, `"es"` for Spanish, or any
other tag for another language. The renderer consumes the authored locale without inferring language
from diagram strings. Documents that omit it remain valid and default to
English.
`meta.locale` controls only renderer-owned reader surfaces: `<html lang>`, the
document-title suffix, default SVG description and focus labels, default legend
labels, and fixed Viewer controls, statuses, accessibility names, and errors.
It never translates authored content. Apply the primary language separately to
titles, subtitles, node and relationship copy, boundaries, lanes, groups,
legend label overrides, and cards. A bilingual diagram still
chooses one primary locale for the Viewer; follow an explicit primary-language
request, then prompt order or conversation dominance.
`en` and `zh-CN` are built-in Viewer catalogs and need nothing further. For
every other `meta.locale`, also set `meta.translations`: an object mapping the
renderer's canonical message keys (`catalogKeys()` in
`renderers/shared/i18n.mjs`) to translated strings whose `{placeholder}` tokens
match the English source exactly. Reuse suitable translations from `examples/locales/` or a previously reviewed
catalog; Spanish uses `examples/locales/es.json`. Translate missing keys or adapt terminology when the diagram needs it;
use the English source to check keys and placeholders. Example catalogs may
lag new Viewer keys; validation reports those gaps and uses English for them. A key that is missing, unrecognized, or has mismatched
placeholders falls back to its English string — `validate`/`render`/`deliver`
report the resulting coverage to stderr — rather than breaking the render or
silently shipping an untranslated string as if it were translated.
For older dev inputs using only `meta.locale: "es"`, copy the Spanish catalog
into `meta.translations` before rendering again. Existing standalone HTML
keeps its embedded translations.
For a requested language you cannot supply `meta.translations` for, do not
write a `meta.locale` with no built-in catalog and no translations. Keep every
reader-facing authored string in the requested language, omit `meta.locale` so
the renderer safely uses English, and explicitly tell the user that fixed
Viewer UI and `<html lang>` remain English and the artifact is not fully localized.
The fallback applies only to renderer-owned surfaces; it never
permits authored copy to fall back to English. Do not silently substitute
`zh-CN` for another language or Chinese locale, and do not machine-translate
`meta.translations` values without disclosing that they are unreviewed.
Keep exact product names, code identifiers, commands, protocols, API paths, and
environment names intact. Those terms may remain English inside localized copy,
but surrounding explanatory prose must still use the selected language.
Renderer-owned default legend labels follow `meta.locale`; author a
`meta.legend.entries.*.label` override only when the diagram needs different
domain wording, and keep that authored override in the primary language.
## Visual preset default
Omit `meta.visual_preset` by default. The renderer then opens the diagram in
`classic` for both light and dark color modes. Color mode and visual preset are
independent viewer state: switching Light / Dark must preserve the current
preset. Author `signal-flow`, `blueprint`, or `editorial` only when the user
explicitly requests that visual style.
## Engineering profile default
Omit `meta.engineering_profile` for an ordinary system architecture. Region,
cluster, and security boundary wording do not by themselves enable an
engineering profile. Enable `deployment-ownership` only when the user
explicitly asks for a production deployment topology, ownership handoff, or
fail-closed deployment review and the source facts are known. Once enabled,
do not remove the engineering profile merely to pass validation; repair the
authored facts or report the diagnostics truthfully.
## Title hierarchy
Use one concise title and let the diagram carry the explanation. Omit
`meta.subtitle` by default, and never use it to restate the title, nodes, edges,
or cards. Include one short supporting line only when the user explicitly asks
for a subtitle; an omitted or blank subtitle must not leave an empty visual row
in the generated viewer.
## Executable geometry rules
Generate one responsive artifact for laptops and external displays, preserving the authored SVG/viewBox, proportions, semantic geometry, and normal document flow. Use meaningful content rows and the Reader-declared readable page-scroll behavior when the complete diagram needs more height; viewport fitting does not authorize alternate topology or smaller typography.
- Node anchors start at side midpoints. `left`/`right` change the horizontal endpoint; `top`/`bottom` change the vertical endpoint. For an automatic Architecture relationship, unobstructed facing ports whose axis offset is under 16px may share one horizontal or vertical axis when both endpoints retain the 16px corner gutter. If exactly one endpoint belongs to a spread group, only its unshared counterpart moves; relationships spread at both endpoints keep their distinct ports and outside bridge unless a reciprocal facing pair can jointly use separate straight lanes while preserving endpoint spacing, labels, and all surrounding route and obstacle clearances.
- A side is a direction contract. The first and final route segment must be perpendicular and outward/inward in the named direction.
- In architecture, data-flow, and lifecycle diagrams, explicit `route: "straight"` requests one direct segment, which may be diagonal when endpoint sides are not pinned. The artifact checker preserves this intent; explicit sides, opaque-node clearance, and other quality gates still apply. `via` takes precedence and retains existing rules, including data-flow's requirement for orthogonal via segments.
- Automatic Port Spread is a default renderer behavior for architecture, workflow, data-flow, and lifecycle diagrams. Shared automatic endpoints spread deterministically and symmetrically with a 16px corner gutter. It does not apply to sequence messages, single relationships, or explicit `via`, `channelX`, `channelY`, `labelAt`, or non-`auto` routes.
- Showcase route rhythm: every nonzero segment must be at least 8px; every interior segment must be at least 16px. When spread ports are nearly parallel, the router uses a 24px endpoint stub and a 16px outside bridge instead of manufacturing a tiny dogleg.
- Showcase route compactness: an explicit Architecture route fails with `composition/excessive-route-detour` when its orthogonal length is at least 2.5 times an obstacle-aware legal route, adds at least 200px, and sends a control point at least 96px beyond the content envelope. The evidence records both lengths, ratio, excess, bounds, and excursion. Remove an unnecessary `via` or move the diagnosed corridor inward instead of enlarging the canvas. Related relationships that overlap on the same outer corridor by at least 32px are treated as an intentional bus and remain valid.
- Shared endpoint corridors are allowed only when they remain semantically unambiguous. Unrelated collinear overlap of 8px or more fails showcase.
- Container borders are intentional pass-through geometry, but a long edge running along a structural border is not.
- An edge crossing an unrelated opaque node is always a hard failure, independent of quality profile.
### Explicit `via` coordinates
Use the resolved departure anchor `S = [sx, sy]` and arrival anchor
`T = [tx, ty]`. Anchors start at side midpoints, but automatic routing and
Port Spread can move them as described above; do not assume an anchor copied
from an automatic route is the anchor of a newly authored explicit route.
Explicit `via` routes do not receive automatic Port Spread.
For the first waypoint `F = via[0]` and last waypoint `L = via[via.length - 1]`,
use these alignments and directions (SVG y increases downward):
| Side | Departure (`fromSide`): `S` → `F` | Arrival (`toSide`): `L` → `T` |
| --- | --- | --- |
| `top` | `F[0] === sx`, `F[1] < sy` | `L[0] === tx`, `L[1] < ty` |
| `bottom` | `F[0] === sx`, `F[1] > sy` | `L[0] === tx`, `L[1] > ty` |
| `left` | `F[1] === sy`, `F[0] < sx` | `L[1] === ty`, `L[0] < tx` |
| `right` | `F[1] === sy`, `F[0] > sx` | `L[1] === ty`, `L[0] > tx` |
For example, given a bottom departure anchor `S = [180, 160]` and a left
arrival anchor `T = [360, 260]`, this relationship fragment leaves downward
and enters the target rightward:
```json
{
"from": "source",
"to": "target",
"fromSide": "bottom",
"toSide": "left",
"via": [[180, 200], [300, 200], [300, 260]]
}
```
The full path is `[180, 160] → [180, 200] → [300, 200] → [300, 260] → [360, 260]`.
Changing only the first waypoint to `[200, 200]` makes the departure diagonal;
changing it to `[180, 120]` keeps its x aligned but leaves upward through the
source instead of outward from its bottom. Both violate `fromSide: "bottom"`
and produce `clean-flow/endpoint-side-direction`. The example establishes
endpoint direction only: keep the full route clear of unrelated nodes and
apply the other geometry rules above.
### Spacing and labels
In showcase Architecture, an unpinned connection label keeps its default position
when clear. If it collides, the renderer tries a bounded set of nearby positions
along the existing route, avoiding nodes, boundary titles, other labels and
other routes within the resolved canvas. Explicit `labelAt`, `labelDx`, `labelDy`
or `labelSegment` (including zero) disables this fallback. Routes and topology
stay unchanged; if no nearby position is clear, validation reports the original
collision. Inspect resolved labels with `--layout-json` before adding controls.
Standard placement retains its existing behavior.
Spacing recommendations mean clear gap between boxes, not center distance. A 200px center distance between 165px-wide nodes leaves only 35px of clear gap.
For a relationship label, require:
```text
clear gap > label mask width + 8px breathing room
label mask width ≈ 6.5px × ASCII units + 13px
CJK characters count as two units
```
Relationship labels are semantic data. If the gap is too small, move the label,
adjust the route or spacing, then shorten the wording while preserving meaning.
Omit only wording already fully implied by both endpoints and carrying no
protocol, action, direction, synchronous/asynchronous behavior, or
cross-boundary mechanism. Preserve every meaningful label.
Deleting it is not a spacing repair. If a relationship starts unlabeled because
its endpoints fully imply it, explain why the wording is redundant; this is a
semantic authoring choice, not a spacing repair. In workflow v2, let the compiler
allocate its measured mask before applying a diagnosed `labelAt`,
`labelDx`/`labelDy`, or `labelSegment`. Apply one diagnosed geometry control at
a time unless several edges share a constrained channel. In that case, plan the smallest coupled change from measured geometry and
validate it together. Architecture/workflow provide layout evidence through
`validate <type> <candidate.json> --layout-json`; for other types, use validation
diagnostics and the rendered SVG geometry.
Before adding manual routes, check whether unnecessary agent-added controls
disable automatic port spread; preserve user-required route intent. Use the
measured clearance rules above rather than guessing coordinates.
### Repair evidence
For architecture, `validate architecture <input.json> --layout-json` exposes the
resolved component boxes, boundary frames, connection points, and label positions.
A measurable rejected layout also returns these fields, with `ok: false`,
`contract: "archify-architecture-layout-v1"`, diagnostics, and exit 1. This is
repair evidence, not artifact acceptance; it writes no HTML. Malformed input or
an implementation failure retains the ordinary failure receipt without layout.
Use the measured failing side for `layout/boundary-out-of-bounds`. Left/top
negative coordinates need an inward move; increasing viewBox width/height only
addresses right/bottom overflow. Boundaries may wrap members across rows. Keep
real membership intact and recheck connected routes after moving members.
Automatic architecture canvases include route points as well as nodes, frames,
and labels. An authored viewBox remains authoritative. In showcase,
`layout/route-out-of-bounds` identifies clipped route points; negative coordinates
need an inward route, while right/bottom overflow can also use a larger authored
canvas. Recheck desktop readability after enlarging a canvas.
When several crossing/corridor diagnoses involve the same nodes, consider their
placement together before adding route controls. Apply one coherent repair and
validate it; independent label nudges cannot fix a shared layout bottleneck.
Compare diagnostics by code, subject, and stage instead of total count alone.
### Repair order
1. Fix missing/invalid `meta.quality_profile` and schema errors.
2. Fix node overlap or out-of-range placement.
3. Fix edge-through-node and endpoint-direction errors.
4. Fix crossings, ambiguous corridors, border runs, excessive detours, and route rhythm.
5. Fix label-to-node, label-to-label, then label-to-route clearance.
6. Fix labels that leave the canvas: move the label with `labelAt`/`labelDx`/`labelDy`/`labelSegment`, or widen `meta.viewBox`. Suggested `labelDx`/`labelDy` values replace the authored field; they are not added to it.
Run `validate` after every edit. Consume `diagnostics[]` by stable `code`, exact `subject`, measured `evidence`, and `supportedFixes`. If the diagnostic gives `labelAt`, use that point instead of estimating another offset.
## Mode placement
### Architecture
Choose overview or mechanism detail using [Composition and meaning](authoring-defaults.md#composition-and-meaning). Use one obvious primary reading path, which may step across meaningful rows when the requested topology needs room. Keep the overview readable at its chosen abstraction; expand implementation details when they answer the reader's question. Group only real ownership, trust, process, or deployment boundaries. Boundaries do not replace relationships.
Grid placement is preferred when the schema supports it. Free positions are appropriate for a bounded exception, not for prose-level coordinate planning. Keep external actors outside the system boundary when that is factually true.
### Workflow
Lanes express responsibility or phase. Columns `0..5` express logical
progression. Start new workflows on `readable-v2`; retain `fixed-v1` only for
legacy geometry compatibility. Keep the happy path monotonic, preserve semantic
edge labels, and route retries and exception returns outside the main lane
corridor.
#### Workflow viewport repair
When `viewer/viewport-overflow` includes `workflowLanes`, inspect the tallest
rendered frames and their node span before changing the source. Measurements
are CSS pixels; space above/below nodes includes lane titles and routing, so it
is not a removable-space budget. Frame IDs identify rendered lane indices.
Run `validate workflow <source.json> --layout-json` and match those frames to
source lanes and nodes. Check whether many steps share the last logical column
and use large `yOffset` values. Readable-v2 currently reserves symmetric space
around offsets and shares the base content height between lanes, so increasing
one offset can enlarge otherwise sparse lanes.
Where the source's ownership and explicit geometry permit, redistribute steps
across logical columns and meaningful lanes, keeping the main path monotonic.
Preserve every required node, relationship, label and semantic check. If ownership
or absolute pins prevent reflow, report that constraint instead of merging lanes
or moving pins automatically. Validate the changed JSON, deliver a fresh HTML,
then rerun browser checks and inspect the first screen; a static pass alone does
not settle viewport fit. These are repair directions, not guaranteed coordinates.
### Sequence
Participants are ordered by conversation role. Messages own their vertical order. Use return/async/security variants for meaning, not decoration; sequence does not use Automatic Port Spread.
### Dataflow
Stages express transformation or custody. Rows separate parallel streams. Label only data contracts, classifications, or cross-boundary movement that is not obvious.
### Lifecycle
Schema v2 (new diagrams): each populated lane is one row, `main` first,
`terminal` last, others in `lanes[]` order. `col` `0..4` is one shared x grid,
so a state placed in the column of the state it leaves gets a straight vertical
transition. Every transition, including the main path, is authored; there is no
implied rail. The renderer sizes the canvas, widens a column gap for a
same-row label, and routes automatic transitions orthogonally through row gaps.
Keep labels short: a gap carrying several parallel lines has little room.
Schema v1 (legacy): main phases use columns `0..4`; event and terminal bands
use columns `0..2`, and event/terminal column `N` aligns with main column
`N + 2`. Every lane other than `main` and `terminal` shares one middle band;
states in the same column there need distinct `yOffset` values.
In both versions a recoverable failure needs a real transition back to an
active state. A card saying “retry” is not topology.
## Repository evidence
When the diagram must reflect real code, inspect repository entrypoints,
runtime boundaries, storage, transports, and deployment configuration before
authoring. Record only evidence you actually verified. `--repo-root <path>` is
accepted by `render`, `validate`, `deliver`, and `preview` for every diagram
type, by architecture `compare`, and by workflow `migrate`; every mode verifies `meta.repository` and
node `sources` the same way. Migrating a source-backed workflow requires the same
`--repo-root` so its candidate is verified before replacing the destination.
Never infer runtime causality from file proximity
or naming alone.
Declare `meta.repository.url` and one full 40-character `revision`, then attach
`sources` to the mode's node collection (Architecture `components[]`, Workflow
and Data Flow `nodes[]`, Sequence `participants[]`, Lifecycle `states[]`) with
repository-relative `path`, optional `line`, `end_line`, and `label`.
Verification reads blobs at that commit, independently of working-tree edits.
Verification ignores local Git replacement refs, including those selected by
`GIT_REPLACE_REF_BASE`, and always reads the original objects at the pinned SHA.
It does not change repository configuration or delete replacement refs.
A matching local origin, available commit, bounded path,
blob, and valid line range are required in every link mode. Verification is
local and makes no remote requests; it establishes neither public availability
nor the current reader's access rights.
`link_mode` defaults to `web`. GitHub and Gitee HTTPS repository URLs generate
revision-pinned links; their public hosts select the provider automatically.
Optional `provider: "github"` or `"gitee"` must agree with the host. Existing
GitHub declarations and default delivery receipt fields remain compatible.
```json
{
"url": "https://gitee.com/team/service",
"revision": "0123456789abcdef0123456789abcdef01234567",
"provider": "gitee"
}
```
For an internal or unsupported forge, select `link_mode: "local-only"`. The
Viewer retains SRC markers, searchable file paths, line ranges, and revision
labels without repository or source hyperlinks. The evidence receipt adds
`linkMode: "local-only"`. `url` remains required as the expected origin identity;
local-only disables links, not identity verification. A repository without an
origin is not supported.
```json
{
"url": "http://git.internal:3000/Platform/Services/service",
"revision": "0123456789abcdef0123456789abcdef01234567",
"link_mode": "local-only"
}
```
Local-only accepts HTTP(S), `git@host:path`, and `ssh://git@host[:port]/path`
addresses, including nested namespaces. Declare a credential-free address;
HTTP(S) credentials on the checkout's origin are ignored for identity and
redacted from diagnostics. Hostnames compare case-insensitively; repository
paths retain case except for the existing GitHub behavior. A trailing slash
normalizes away. Only GitHub and Gitee normalize a terminal `.git` and match
standard HTTPS/443 with Git SSH/22. For other hosts, use the actual clone address:
transport, port, `.git` suffix, and remote-relative versus absolute paths must
match. For example, `git@host:Team/repo` differs from
`ssh://git@host/Team/repo`; `git@host:/Team/repo` matches the latter. SCP-style
paths preserve literal percent escapes, while URI paths decode them. SSH host
aliases and forge-specific browse/clone prefixes are not guessed.
GitLab/Gitea/Forgejo/Bitbucket web links are not implemented in this version;
use local-only until a tested link provider is available. Unknown web providers
fail with a diagnostic rather than emitting a guessed link.
## Hand-placed fallback
Use only when no renderer can run. Start from `assets/template.html`, keep semantic CSS classes, preserve the inline SVG/accessibility structure, and run the delivery visual checklist. Never introduce inline literal colors that break dark/light parity.
## Node icons
For domain-specific diagrams, set an optional `icon` on architecture components,
workflow/dataflow nodes, sequence participants, or lifecycle states. Choose
`calendar`, `clock`, `person`, `briefcase`, `flag`, or `moon` for everyday concepts;
the complete catalog (including existing technical and lifecycle symbols) is
`common.schema.json#/$defs/nodeIcon`. Use `icon: "none"` to hide the corner symbol.
Omitting `icon` keeps the type-based default. These inline SVG symbols are
renderer-owned and export with the diagram; URLs and raw SVG are not accepted.
Icon selection changes only the corner symbol. The node's type still determines
color and semantic grouping; brand marks remain independent. For a holiday
workflow, pair `type: "backend", icon: "calendar"` with
`meta.legend.entries.backend.label: "假期"`, and use `icon: "briefcase"` plus
an appropriate legend label for make-up work. Keep the node label meaningful:
icons are decorative and are hidden from assistive technology.
See [holiday planning](../examples/holiday-planning.workflow.json) for a complete workflow example.
+41
View File
@@ -0,0 +1,41 @@
# Authoring defaults
Read once before writing a fresh candidate. An existing frozen candidate going straight to `finalize` needs this only if repair changes its authorship.
## Composition and meaning
For Architecture, default to a system overview led by the main user journey unless the user asks for a narrower mechanism, module map, or deployment topology. Group cooperating roles in accurately named subsystems when that still explains the requested interaction. Separate roles when grouping would hide control ownership, a trust or persistence boundary, lifecycle behavior, or another distinction the reader asked about. Keep secondary and opt-in capabilities in concise sourced notes unless their path matters to the requested question. Name a deliberately narrower scope in the title.
Preserve every requested responsibility, relationship direction, protocol, and behavior-changing condition. Show approval, authorization, and state-transition gates on the affected node or relationship; a card alone cannot qualify an otherwise unconditional arrow. Keep source evidence with each asserted claim. Boundaries express real isolation, ownership, runtime, or persistence facts. Cards answer additional reader questions; they do not replace required topology. There is no node, edge, source, card, or boundary quota. If an authored overview needs regrouping after failure, use [Composition repair](authoring-contract.md#composition-repair); user-supplied or agreed topology remains fixed.
Relationship labels carry meaning. Give them clear space and preserve action, protocol, direction, async behavior, or cross-boundary meaning. A label may start absent only when both endpoints already fully imply it; a collision calls for spacing or routing repair. See [Label repair](authoring-contract.md#label-repair) when measured evidence reports a collision.
## Layout and routing
Place Architecture nodes by their actual connections before assigning coordinates; the router cannot rearrange boxes, so placement decides whether lines stay straight. Classify each relationship first, then place:
- **Main path**: the reader's main journey, neighbors adjacent in reading order. Let a medium path step through meaningful rows instead of making a shallow horizontal strip.
- **Branch or store**: directly above or below the node that owns, reads, or writes it, centered on that node so the edge is one straight segment. Keep all stores and branches of one row on the same side of it.
- **Return** (back to an earlier main-path node): put its source on the side of the main path with no branches or stores, so it runs through an empty corridor instead of crossing them.
- **Second entrance** into a node that already has an incoming edge: place the new source so it reaches that node from another side, usually directly below or above it.
- **Fan-out**: a side with k relationships needs at least `32 + 14 × (k − 1)`px (four need 74px). Spread a hub's counterparts over two or three sides, or enlarge the hub. Center a parent on its children and align a child with its only parent.
Before writing positions, trace each non-main relationship: its straight or one-bend corridor must not pass another node or cross another relationship. If it does, move the endpoint that is off the main path. Start the main actor and its first connected step together near the canvas origin; use content rows for vertical rhythm. Omit `meta.viewBox` for a fresh Architecture so the Reader measures intrinsic height. Keep supplied fixed geometry authoritative.
Start with automatic routes and endpoint sides. Pin a side only for a necessary branch, return, or supplied geometry. Reserve `via`, `channelX`, `channelY`, and label coordinates for measured defects. Before writing positions, budget each labeled main-path edge at `6.5px × ASCII units + 21px` of clear gap, counting CJK as two units; use its own label length, not a row-wide fixed gap. Size Architecture sublabels for their preferred 9px text at `5.4px × text units + 8px`, with CJK counting twice; keep supporting copy concise without dropping required facts. Do not trade readability or meaning for fewer crossings. The [Geometry reference](authoring-contract.md#executable-geometry-rules) has measured spacing, port, canvas, and route rules for a diagnosed layout problem.
## Evidence and schema
For a real repository, follow [Repository authoring](repository-authoring.md) while inspecting source. Freeze its credential-free origin and 40-character commit in `meta.repository`, attach inspected repository-relative `sources` to each key semantic node, and pass `--repo-root` to the first `finalize`. Each reference proves only the fact visible at that location. Follow material relationships and conditions to their actual source; do not reuse a startup citation as protocol or persistence evidence.
Examples show field shape, not legal values or source facts. Read the mode schema and shared definition before adding a field, enum, or constrained text. In particular, inspect Architecture boundary kinds. Keep longer evidence in a card while retaining the fact. See [Schema lookup](authoring-contract.md#schema-lookup) for details.
## Presentation and modes
Use one primary authored language from the user's choice or the request/conversation. Set `meta.locale` for built-in English (`en`) or Simplified Chinese (`zh-CN`); for other languages, including Spanish (`es`), supply `meta.translations` with reusable UI translations, or disclose the fixed Viewer UI and `<html lang>` English fallback. Keep exact product, code, protocol, command, API, and environment names while localizing surrounding explanation. See [Language consistency](authoring-contract.md#language-consistency) for bilingual cases.
Omit `meta.visual_preset` for classic, `meta.subtitle` for a title-only header, `meta.legend` for truthful auto, and `meta.engineering_profile` for an ordinary system overview. Explicit styles and a subtitle require a user request. Use legend or deployment ownership under the [legend](authoring-contract.md#legend-contract) or [engineering profile](authoring-contract.md#engineering-profile-default) contracts. Branding is optional and explicit when a node names a real product; [Brand marks](brand-marks.md) gives lookup and capture rules for that branch. Never let a badge replace semantic type, label, or relationship facts. Set required `meta.output` to a portable POSIX-relative `.html` path within the working directory; see [Output path contracts](delivery-contract.md#output-path-contracts) for native path exceptions.
For new Workflow use schema v2, preserving v1 for a fixed legacy source; use its [layout contracts](../renderers/workflow/README.md#layout-contracts) when lane or group geometry needs detail. For Sequence start with fixed columns; use `spread` when a wide viewBox leaves unused horizontal space or meaningful labels need width. For new Lifecycle use schema v2: every lane is its own row (`main` first, `terminal` last) and `col` `0..4` is the same x in every row, so place an interruption or exit in the column of the state it leaves; author the main path as transitions, omit `viewBox`, and keep transition labels short; a recoverable failure needs a real transition back. Read [Mode placement](authoring-contract.md#mode-placement) when a mode-specific placement or viewport problem needs more detail.
`finalize` performs the browser gate. Keep the complete drawing comfortably readable on desktop, with zero horizontal overflow. Use meaningful vertical rows and intrinsic-height page scroll when necessary. The 6px projected-text check is a failure floor; at 1440px, aim for ordinary context text around 7.5px or larger. See [Automated browser evidence](delivery-contract.md#automated-browser-evidence) when viewport evidence fails.
+82
View File
@@ -0,0 +1,82 @@
# Brand marks
Use a brand mark only when a real product, provider, model family, channel, or
service identity helps the reader. Semantic `type` still explains what the node
does; `brand` explains whose product it is.
## Agent decision path
1. Search the built-in catalogue when the request names a recognizable brand:
```bash
node bin/archify.mjs brands "Claude" --json
```
2. Put the returned canonical ID in the node, participant, or state:
```json
{
"id": "planner",
"type": "backend",
"label": "Claude",
"brand": "claude"
}
```
3. If there is no catalogue match and the user supplied the official website,
capture its icon explicitly:
```bash
node bin/archify.mjs brands capture "https://partner.example.com" --json
```
Put the command's digest-pinned `brand` value in the authored node:
```json
{
"id": "partner",
"type": "external",
"label": "Partner portal",
"brand": {
"url": "https://partner.example.com",
"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}
}
```
4. If there is no match and no user-provided URL, omit `brand`. Do not invent a
URL or silently assign a visually similar company.
Known-brand URLs resolve to the bundled vector instead of using the network.
For discovered icon `href` attributes, capture decodes the basic named references
`amp`, `quot`, `apos`, `lt`, `gt` (and their defined uppercase aliases), plus
decimal and hexadecimal numeric references, once before URL resolution. Thus
`/icon.png?v=1&amp;size=32` requests `/icon.png?v=1&size=32`. URL percent escapes
remain intact; nested escapes are not decoded recursively. This bounded decoder
does not add a general HTML parser or support every named HTML entity.
HTML reads stop at an explicit head ending outside comments, raw-text elements
and quoted attributes, including when those tokens span network chunks. The
256 KiB head limit and capture deadline still apply; a larger body after the
head is not read for icon discovery.
Unknown URL capture accepts only bounded raster image formats, blocks
credentials, nonstandard public ports, and private or link-local destinations,
uses bounded concurrency and one total deadline, and returns the captured
content digest. Later render and validate operations require that exact digest;
blocked, unavailable, changed, oversized, or unsafe content fails closed instead
of silently changing the artifact.
Page, icon and redirect requests send `Accept-Encoding: identity`. Capture does
not decompress response bodies: a successful response declaring another content
coding is closed and rejected explicitly. This keeps the existing byte limits
and pinned digest tied to the unencoded representation. A later usable icon may
still succeed; otherwise an encoding error is retained instead of being hidden
by an unrelated favicon 404.
The final artifact never fetches a brand asset when opened. Preset vectors and
digest-verified captured site icons remain embedded in SVG, PNG, WebP, JPEG,
Share Card, and WebM exports.
Use `node bin/archify.mjs brands --json` to inspect all canonical IDs, aliases,
categories, domains, and provenance. Current categories cover AI, cloud,
engineering, data, collaboration, business systems, channels, languages, and
frameworks.
+588
View File
@@ -0,0 +1,588 @@
# Delivery contract
## Failed finalize and candidate repair
`finalize` stops at the first non-passing gate. Use compact stdout or `evidence.summaryReceipt`; read its full sidecar only when the summary lacks evidence needed for a coherent repair. A receipt with four artifact checks is basic validation, not showcase acceptance: require all nine checks, zero composition errors, and zero warnings. Fix `meta.quality_profile` and schema errors before geometry.
For a validation failure, edit the existing JSON in the connected neighborhood named by diagnostics before rerunning a command. Preserve requested semantics, meaningful labels, source evidence, and fixed or agreed topology. Several routes sharing nodes call for one placement repair; read [Architecture layout repair](architecture-layout-repair.md) for that case. Reflow a blocked main path rather than nudging unrelated labels. Keep unrelated geometry when its composition already reads clearly. Use `--layout-json` before editing only when compact evidence lacks needed measurements. Workflow v2 uses its stable compiler receipt, not solver internals, as authoring evidence.
After the edit, rerun the complete `finalize` command with `--quality showcase` and, for repository-backed work, `--repo-root <repo-root>`. If the output path already has browser evidence from another candidate, use a fresh `--out-dir <output-stem>.review-<revision>` for both the new `finalize` and any `visual-check`. Omit an earlier `--candidate-sha256` after editing because it binds the previous candidate. Compare diagnostics by code, subject, stage, and evidence, never by declining error count alone. If an issue survives two focused repairs, inspect measured geometry or the relevant contract; after one evidence-based retry, report the concrete gap.
Use standalone `validate` only for focused diagnosis, passing `--repo-root` for repository-backed work. Its passing receipt marks `candidateFrozen: true`; run `nextAction.arguments`, replacing only `<output.html>`, without editing, revalidating, or rereading the candidate. Retry later environmental or evidence failures against those frozen bytes. A measured reason to edit creates a new candidate and calls for the complete `finalize` without the old hash.
## Validate and deliver
`render` and direct renderer entry points print classified authoring failures
to stderr as readable diagnostics and exit 1. Input read/JSON parse failures
use `input/read` or `input/json-parse`; output filesystem failures use
`output/write` and identify the output path. Schema and layout failures keep
their existing rule codes. Use the advertised `validate --json` or
`deliver --json` interface for a machine receipt; `render` has no `--json` flag.
Unexpected implementation failures retain debugging information in human
mode and remain `internal/unclassified` in machine receipts.
Each delivered output has two artifact-specific metadata paths. When an output
stem is too long for those derived filenames, Archify shortens it and appends a
stable hash:
- `<output-stem>.delivery.json` records the latest completed attempt.
- `<output-stem>.delivery-pending.json` is the recovery journal for an attempt
in progress.
For a literal artifact stem that already matches Archify's reserved bounded-name
marker, a pre-namespace raw provenance sidecar remains a read fallback when the
encoded sidecar is absent. A pre-namespace raw pending journal is an independent
fail-closed barrier: it blocks checks and redelivery even when an encoded pending
journal also exists, and neither journal is silently replaced.
One directory-wide `.archify-delivery-lock.json` serializes every delivery that
resolves into the same physical output directory. This deliberately prevents
case, Unicode-normalization, Windows short-name, and symbolic-link aliases from
creating independent owners for one filesystem location. The tradeoff is that
deliveries to different artifact names in one directory also run serially;
provenance and pending journals remain artifact-specific.
For migration safety, Archify also detects and preserves a legacy
`<output-stem>.delivery-lock.json` beside the requested artifact. An existing
legacy entry is a fail-closed recovery barrier. While a new delivery owns the
directory mutex, it also holds temporary legacy-format fences for the requested
spelling and an existing artifact's physical target spelling. It acquires the
directory lock first, then all required compatibility fences before writing a
journal or artifact, and removes the directory lock before those fences during
release. This blocks an older Archify binary using
either known spelling from entering the delivery. A legacy fence whose raw
HEAD-era filename exceeds the host component limit is omitted because the old
binary could not create that lock or deliver that artifact on the host either.
`deliver` acquires the lock by exclusive `open(..., "wx")` before creating or
replacing the recovery journal. A successful exclusive create yields an
internal opaque ownership capability bound to that attempt. Journal creation,
failed-provenance recording, pair commit, rollback, journal finalization, and
lock release each verify the current capability inside the operation that
would mutate shared state. A rejected contender does not create a journal or
write failed provenance.
An existing directory or legacy lock is handled without automatic recovery:
| Observed lock state | Required `deliver` result |
| --- | --- |
| No directory entry | Attempt exclusive creation; only its success grants ownership. |
| Valid schema-v1 lock whose PID is running, or whose death cannot be established | Exit 1 with `delivery/concurrent-attempt`; preserve every shared path. |
| Valid schema-v1 lock whose PID is known to have exited | Exit 1 with `delivery/lock-stale`; preserve the lock, artifact, journal, and current provenance exactly. |
| Unreadable, malformed, symlink, dangling symlink, directory, or other non-regular lock entry | Exit 1 with `delivery/lock-invalid`; preserve the entry and every other shared path. |
| An acquired capability no longer matches the current lock or journal | Exit 1 with `delivery/ownership-lost`; stop all shared-path mutation. |
| The matching owner cannot remove its lock | Exit 1 with `delivery/lock-release`; preserve the lock. |
A `delivery/lock-stale` diagnostic identifies the absolute output and lock
paths plus the original PID and receipt ID. Recovery is deliberately explicit
and serial: stop all delivery attempts for that physical output directory,
confirm that no active delivery owns it and that the reported stale entry has
not been replaced, remove only the reported lock, then rerun `deliver`. Do not
remove an artifact, current provenance, or pending journal as part of
stale-lock recovery.
The lock protocol targets Node.js 18 or later on a local filesystem with
cooperating Archify processes. PID, receipt, and file-identity comparisons are
defensive checks, not an atomic compare-and-swap. Compatibility fences cover
the requested spelling and an existing physical-target spelling; they cannot
enumerate arbitrary hard-link names or previously unknown filesystem aliases,
so mixed-version delivery through such aliases remains out of scope. This
contract does not claim distributed-lock correctness on NFS, SMB, or other
network filesystems, and it cannot prevent an external process that ignores
the protocol from replacing shared paths.
Every no-clobber HTML publisher (`render`, `deliver`, `compare`, and `preview`)
captures the requested directory entry, canonical write slot, physical parent,
and existing target type, device/inode identity, and mode before staging, then
revalidates that snapshot immediately before replacement. An existing write
target must be a regular file with exactly one hard-link name. A target with
multiple hard-link names fails closed with `output/target-hardlinked`: replacing
the requested name cannot update unknown sibling names as one publication.
Hard links remain supported for read identity and input/alias collision checks;
they are unsupported only as write targets. A symbolic link to a single-link regular
file remains supported: publication preserves the symbolic-link entry and
applies the same protocol to its resolved target. Directory, FIFO, socket,
device, changing mode, new claimant, and indeterminate identity cases fail
before replacement.
Publication is no-clobber and recoverable, not crash-atomic replacement of an
existing target. To avoid overwriting a claimant that appears after the last
identity check, Archify first retains the bound old file in a private recovery
backup, removes the public name through identity-bound quarantine, and then
creates the new public name with an exclusive hard link. A caught failure rolls
back when the public slot and recovery binding still permit it. A process
interruption between those namespace operations can instead leave the public
path absent while the verified previous bytes remain in an adjacent private
recovery backup. Single-artifact publication records the original slot/alias
identity and backup inode, mode, SHA-256, and byte count in private
`.archify-remove-*/publication-recovery-v1.json`, beside `previous`. To make a
specific interrupted publication visible again, stop concurrent writers and run:
```bash
node bin/recover-output.mjs /absolute/path/to/.archify-remove-<id> --json
```
This is explicit recovery, not a directory scanner. Before linking, the helper
checks for a changed parent or alias, an altered/hardlinked record or backup,
digest or inode mismatch, and any existing public target. It restores only by
no-clobber hard link, so a new claimant is preserved rather than overwritten;
it never recursively removes unknown entries. A completed recovery is
idempotent. The record is evidence to be independently verified, not an
authority to restore arbitrary private bytes: the helper accepts it only from
the recorded generated child of the original physical target parent, with the
same directory identity. Name the exact directory reported by the interrupted
process and inspect an uncertain record manually. A non-cooperating process can
still swap pathnames after those checks and before Node.js `linkSync`; Node does
not expose a descriptor-bound link operation. Post-link identity verification
then fails closed and retains recovery evidence, rather than claiming recovery
or deleting an uncertain name. If recovery itself is interrupted after the
link, the old public bytes and private backup can both remain; a later recovery
run preserves the public target and needs explicit operator resolution. The
record is fsynced before the old public name is retired on platforms supporting
directory sync, and the tested guarantee is recovery after a killed process;
this is not a claim of power-loss, storage-controller, NFS, or SMB durability.
Paired flows retain their backup in private transaction staging. For `deliver`,
the pending journal and lock keep strict checkers fail-closed. The portable
Node.js filesystem API has no pathname
compare-and-swap that both replaces an existing name atomically and refuses to
overwrite a late claimant: `rename` would close the visibility gap only by
overwriting that claimant.
After ownership is established, `deliver` creates the journal before rendering
and keeps it through the recoverable HTML/sidecar pair commit. It removes the
journal only after that commit completes. A validation, render, or pair-commit
failure, or a process interruption, may therefore leave a journal. The journal
is a safety barrier: `check`, `browser-check`, and `visual-check` fail closed when any directory
entry exists at the journal or lock path, including an unreadable file,
symlink, or dangling symlink. Run deliveries targeting the same physical output
directory serially; one attempt must finish or be recovered before another
begins.
A successful sidecar has `schemaVersion: 1`, `status: "current"`,
`command: "deliver"`, a unique `receiptId`, the diagram `type`, an absolute
`input` path, an absolute `output` path matching the inspected
artifact, and specification/artifact SHA-256 and byte counts. Checkers treat a
missing, malformed, unsupported, or inconsistent field as invalid. They also
reject a sidecar symlink, including a dangling one. A checker binds provenance
to the artifact bytes it actually checks and verifies that binding again before
reporting success; a concurrent byte change fails. The provenance directory
entry itself must be a single-link regular file: `deliver` and strict check fail
closed with `delivery/provenance-hardlink-unsupported` when it has another hard
link, without scanning for or guessing the sibling name.
If a currently verified owner fails after an older HTML exists, Archify writes
a new `status: "failed"` sidecar and leaves the journal until recovery is
complete. An unreadable old HTML does not prevent that marker; its artifact hash
and byte count may be absent. If the sidecar is locked or otherwise unwritable,
Archify keeps the prior sidecar rather than deleting evidence, and the journal
prevents checkers from trusting it. A rejected concurrent, stale, or invalid
lock attempt does not write failed provenance. If ownership is lost, Archify
reports `delivery/ownership-lost`, does not overwrite or remove the successor's
artifact, provenance, journal, or lock, and does not claim recorded failed
provenance; a failure receipt may report `provenance: "unrecorded"`. If every
metadata path is unavailable, the same unrecorded status applies; no tool can
preserve that fact across processes. Restore metadata-path access and complete
a successful `deliver` before trusting the output.
Artifacts with no sidecar, journal, or lock remain supported for backward
compatibility and for the lower-level `render` command. Their checker receipts
report `provenance: "unknown"`; use `--require-provenance` to turn that state
into a non-zero failure when the workflow requires a successfully delivered
artifact:
```bash
node bin/archify.mjs check <output.html> --require-provenance
node bin/archify.mjs browser-check <output.html> --json --require-provenance
```
## Output path contracts
Archify intentionally separates durable authored paths from command-line paths:
- Required authored `meta.output` is a portable POSIX-relative path such as
`reports/diagram.html`. It uses `/`, ends in a non-empty `.html` basename,
and cannot contain an absolute or drive-relative prefix, URI, backslash,
empty or dot segment, control character, unpaired UTF-16 surrogate, Windows
alternate-data-stream separator or invalid filename character, trailing dot
or space, DOS device name, or a component over either the 255-byte UTF-8 or
255-code-unit UTF-16 limit. It resolves from the current working directory
and must remain physically inside that directory, with an `.html` target,
after symbolic links are followed. The durable output/archive profile also
conservatively rejects a Windows 8.3 short-name shape such as `PROGRA~1`;
descriptive repo/Git POSIX paths use a separate profile and are exempt.
- Explicit CLI output arguments use the active host's native syntax. They may
be relative or absolute, use native separators, and resolve outside the
current working directory. On Windows, ordinary drive-absolute, UNC, and
relative paths (including ordinary `.` and `..` navigation) are supported.
A system-resolved 8.3 spelling of an existing file or directory is accepted
when Archify can prove its physical identity; this native alias support does
not relax the durable output/archive profile's 8.3-shaped-name rejection.
Extended-length paths are limited to raw backslash-only `\\?\C:\...` and
`\\?\UNC\server\share\...` forms without dot segments; device namespaces,
malformed roots, drive-relative paths such as `C:file.html`, current-drive
roots such as `\file.html`, alternate data streams, reserved device names,
invalid or trailing filename characters, and overlong components fail
closed. POSIX CLI paths retain POSIX filename rules rather than inheriting
Windows spelling restrictions. Every host rejects NUL, unpaired surrogates,
and components that exceed its supported bound.
These contracts are not interchangeable: an explicit CLI output does not hide
an invalid durable `meta.output` (including a missing value), and `validate`
checks the authored output even when it does not publish to that path. A
workflow v1-to-v2 migration may explicitly receive a portable durable
replacement through `migrate workflow old.json new.json --to-schema 2 --output
reports/diagram.html`; that value is written only to its separate verified v2
destination. This migration-candidate exception does not repair the source or
bypass any non-output schema or compiler error. For every other repair, add a
portable POSIX-relative `.html` path to `meta.output`; no schema-version change
is otherwise required.
Run `finalize` directly on a complete first candidate and after every repair edit. Its embedded validation checks the candidate before delivery; use standalone `validate` only for focused diagnosis. After an edit, omit any earlier `--candidate-sha256`, which binds the previous candidate. CLI HTML output paths must end in
`.html`, including after symbolic-link resolution. Compare receipt paths must
end in `.json`. A type mismatch fails before writing with
`output/cli-extension` or `output/cli-resolved-extension`. These checks prevent
accidental file-type overwrites; they do not sandbox explicit CLI directories
or prevent replacement of an existing artifact of the expected type.
Use final verified delivery only after the candidate is frozen:
```bash
node bin/archify.mjs deliver <type> <candidate.json> <output.html> --quality showcase --json
```
Deliver reads the specification once, writes those exact bytes to a private same-directory candidate snapshot, renders that snapshot, runs the complete artifact checker, and only replaces the target after all artifact checks pass. The JSON receipt includes SHA-256 and byte counts for both `specification` and `artifact`.
For the ordinary agent handoff path, prefer the finalizer:
```bash
node bin/archify.mjs finalize <type> <candidate.json> <output.html> --quality showcase --json
```
`finalize` invokes verified `deliver` once, reuses its embedded showcase
validation result, then runs strict `check --require-provenance` and
`browser-check --require-provenance`. It stops at the first failed or skipped stage
and preserves that stage's full receipt. Its stdout is one compact JSON
object with gate statuses, bounded actionable diagnostics, artifact identity,
and evidence paths. The same compact object is written atomically to
`<output-stem>.finalize-summary.json`; use that file for normal failure repair.
Complete stage receipts and timings remain available for auditing in
`<output-stem>.finalize.json`. With `--out-dir`, both files are written there;
`--receipt <path.json>` overrides the full receipt path and derives a distinct
`<path>-summary.json`. Read the full receipt only when the compact summary is
truncated and its shown subjects and evidence cannot identify a coherent local
repair, or when complete audit evidence was explicitly requested. The compact
receipt reports `visualReview: "not-requested"`; the automated gate does not create images or require a perceptual reviewer. A compact `visualReviewRecommendation` retains positive crossover and route-detour metrics from the strict check so the author can apply the review escalation below without reading the full receipt. A recommendation does not change the machine exit code or claim that review happened. Its `affectedRoutes` identifies crossing pairs and detours (up to eight of each, with a truncation flag); the full strict-check `composition.routeReview` retains all affected relationships. Use these IDs to trace the routes in the captured default viewport. Detours may include `directCorridorBlockers`, identifying nodes between aligned endpoints. These are geometric review clues, not new validation failures or inferred main-path semantics. For a blocked main path or several tangled routes, follow [Architecture layout repair](architecture-layout-repair.md) and reflow the connected scene before tuning individual sides or labels. Preserve every semantic fact; retain unrelated positions only when their surrounding composition is already accepted.
For a measured automatic Architecture with a large unused leading area, the
compact receipt may include `layoutReviewRecommendation`. Its
`composition.leadingSpace` evidence accounts for nodes, boundary titles,
routes and labels. Check whether that space is intentional; if not, reposition
the connected scene while preserving meaning and user-fixed geometry, then
finalize again. This suggestion changes no gate or exit status and requires
no screenshot. A fixed canvas or uncertain measurement receives no suggestion.
A passing finalizer receipt is sufficient evidence for all four gates. Merely
naming the gates or requiring each one to pass does not require replaying their
standalone commands. Replay an individual command only when the request
explicitly requires separate executions or focused failure diagnosis needs it.
The individual commands remain authoritative and backward compatible. Use
them directly for focused diagnosis, recovery, or when only one gate is
required. A finalize failure does not relax any gate and does not turn a
preserved older artifact into a current successful delivery.
`finalize` overlaps private Chrome startup with delivery and strict checking.
It loads the artifact only after those gates pass and current provenance is
verified. The browser gate retains every viewport, theme, and stability check;
the browser closes at completion or an earlier failure. Its full stage receipt
records `execution: "in-process"` and the equivalent standalone `command` for
replay. Use total finalize duration to compare performance because Chrome
startup overlaps the earlier stages.
The pair commit is recoverable, not a claim that two filesystem paths change
atomically or are durable across power loss. Journal finalization is part of
that commit: a caught failure while verifying or removing the journal rolls
back the replaced files when possible and while ownership remains current. If
ownership is lost, the old attempt immediately stops renaming, rolling back,
finalizing the journal, recording failure provenance, or cleaning up shared
paths. Any private staging or recoverable backups remain available and are
identified by the failure diagnostic. If restoration fails for another reason,
the failure receipt likewise identifies retained backups for recovery. A
process interruption can leave the journal, backups, or private staging behind;
checkers then fail closed. Follow the reported recovery evidence before rerunning
`deliver` serially on the same output. A failed attempt exits non-zero and never invokes an opener; it never
authorizes visual evidence collection.
If exclusive creation succeeds but lock initialization fails, Archify may
record failed provenance and remove the incomplete lock only while its
capability still identifies that exact entry. A replacement is preserved.
Filesystem and cleanup errors are reported separately from an active concurrent
delivery. An active, stale, unrecognized, or otherwise preserved lock
independently prevents checkers from accepting the prior artifact. Fix the
reported filesystem error before retrying, and use another physical output
directory if the lock path contains unrelated data.
Lock release is part of delivery completion. If the artifact/provenance pair
has committed and the journal has finalized but the matching lock cannot be
removed, `deliver` exits 1 with `delivery/lock-release`, preserves the lock,
does not print a success receipt, and does not invoke an opener. The preserved
lock keeps strict checkers fail-closed. Only after pair commit, journal
finalization, and lock release all succeed may `deliver` exit zero, print its
success receipt, or run `--open`.
Run strict `check` after `deliver` exits zero. Run `browser-check` or optional
`visual-check` only after that strict check exits zero. A failed marker,
recovery journal, or delivery lock makes every checker fail before accepting
the preserved HTML; report the diagnostics and complete a successful recovery
delivery before collecting new browser evidence.
The delivery interface exposes four separate claims:
1. `deliver` proves deterministic artifact checks and byte identity.
2. `browser-check` collects required automated browser evidence from the exact artifact without capturing images.
3. `visual-check` optionally adds artifact-bound screenshots and a contact sheet.
4. Perceptual visual review records a human or image-capable reviewer's judgment.
Passing one claim never implies the others. Never claim that the deterministic receipt includes browser or perceptual review evidence.
## Recovering a failed comparison
`compare` commits an HTML artifact and its JSON receipt as a pair. If that commit
fails, it attempts to restore the previous files. A complete rollback removes
the temporary directory as usual.
If a previous file cannot be restored, compare exits non-zero with
`delta/commit-rollback-failed` and retains the recovery directory. In the JSON
failure receipt, `diagnostics[].evidence.recoveryDirectory` identifies that
directory and `recoveryFiles` lists `{ backup, target }` paths for the files whose
restoration failed. Human-readable diagnostics also print the recovery paths.
Resolve the filesystem error, inspect the current targets, and restore each
listed backup to its corresponding target before retrying. Keep the recovery
directory until both previous files have been recovered and verified; it can
also contain rejected candidate files, which must not be mistaken for backups.
Successful comparisons and failures before commit retain their normal cleanup.
## Automated browser evidence
`finalize` runs the required browser gate against the exact trusted HTML without
rerendering or modifying it. For focused diagnosis, the equivalent standalone
command is:
```bash
node bin/archify.mjs browser-check <output.html> --json --require-provenance
```
The zero-dependency command uses Chrome/Chromium through the DevTools pipe. It
measures light-theme containment at 1440×900, 1600×1000, 1920×1080, and
2048×1320, and verifies the requested light theme at all four viewports, the dark
theme at both endpoints, and READ/Still runtime states. A requested theme that
resolves to a different theme fails with measured evidence. It creates one
`<output-stem>.browser-check.json` receipt and no screenshots or contact sheet.
Pass `--out-dir <dir>` to place the receipt in a separate evidence directory.
The receipt binds the artifact SHA-256 and byte count, identifies
`evidenceKind: "automated-browser"`, and reports
`visualReview: "not-requested"`.
Horizontal overflow always fails. Normal document-level vertical scrolling is
accepted only with a renderer-declared contract and measured readable text.
Automatic canvases declare `data-reader-fit="intrinsic-height"`; their adaptive
Reader must reach its readable width and expose `data-reader-overflow="authored"`.
Architecture with an explicit `meta.viewBox` instead declares
`data-diagram-type="architecture"` and `data-reader-fit="authored-height"`:
its SVG coordinates, aspect ratio and existing Reader width behavior stay
unchanged. Its full SVG must remain inside the diagram panel without internal
scrolling or clipping, and the document must permit vertical scrolling.
The receipt records `verticalScrollAccepted: true` and
`overflowDisposition: "readable-vertical-scroll"`. Missing or unknown declarations,
explicit viewBoxes in other modes, unreadable text, horizontal overflow,
clipping and Viewer chrome collisions remain failures. Do not add an internal
diagram scroller or hide overflow.
`browser_evidence` in the handoff records only the outcome of this automated
command:
- `passed` maps from exit 0 and receipt `status: "pass"` after every required measurement completes and passes.
- `failed` maps from exit 1 and receipt `status: "fail"` when the inspection finds a defect, the command fails, or a runtime error leaves the evidence incomplete.
- `skipped` maps only from exit 2 and receipt `status: "skipped"` when Chrome/Chromium is unavailable and the inspection does not run.
Runtime failures leave incomplete evidence and must not be normalized to
`skipped`. They do not invalidate an already successful deterministic delivery.
Retry an environmental failure in a browser-capable execution context when
practical. Keep the packaged transport unchanged unless the failure reproduces
through that seam in a capable environment.
A provenance failure exits before browser inspection and persists a failed
browser-check receipt bound to the attempted artifact. If the failure receipt
cannot be written, the diagnostic names that incomplete evidence.
Both browser commands inspect the exact delivered HTML without modifying or rerendering it.
## Sequence width review
A passing `finalize` may report `layoutReviewRecommendation.action: "inspect-sequence-width"`. Its `evidence` measures the fixed participant columns' unused right-hand space after accounting for message labels, notes and segment titles. This advice adds no warning, failure, screenshot requirement or automatic geometry change.
For a newly authored candidate with omitted `meta.column_fit` and no user-fixed column geometry, save the candidate, set only `meta.column_fit` to `"spread"`, and rerun the complete `finalize` once with `--out-dir <folder>/width-review`. Keep participant order, messages and their y positions, labels, notes, sources and canvas dimensions. If that attempt fails, restore the candidate and finalize it with `--out-dir <folder>/width-restore`; report the remaining layout suggestion rather than iterating. Preserve an explicitly fixed layout or a supplied legacy candidate and disclose the suggestion without changing it. This review is about horizontal composition; a passing receipt still does not claim perceptual approval.
## Optional capture evidence
`visual-check` remains backward compatible for a requested or escalated
perceptual review:
```bash
node bin/archify.mjs visual-check <output.html> --summary --require-provenance
```
`--summary` returns compact JSON with all diagnostics and absolute paths to the complete receipt, contact sheet, and every screenshot. For a chosen visual review, inspect the relevant captures; capture success is not perceptual approval. `--json` retains the full receipt output for existing consumers. Both modes run the same checks and keep the same exit status. If cleanup fails after publication, the summary retains the final failure diagnostics and `publication` recovery details; the linked receipt records the earlier committed evidence.
It performs the same automated browser measurements, captures light/dark
screenshots at 1440×900 and 2048×1320, and writes four viewport PNG sidecars,
one relative-path HTML contact sheet, and one JSON receipt. `--out-dir <dir>` moves all of these sidecars together.
Open the HTML contact sheet in a browser or inspect the viewport PNGs with an image reader. Its receipt reports `visualReview: "pending"` because captures do not themselves
make a perceptual judgment. Capture and provenance failures follow the ownership rules below.
The receipt, contact sheet, and four PNGs form one owned evidence set. Before
capture, `visual-check` freezes every requested directory entry, its
canonical write slot and physical parent, and the target's absent/file state,
type, device/inode identity, and mode. Hard-linked evidence targets are not safe
write targets. All candidate files are created exclusively inside one random,
private staging directory beneath the physical evidence directory; the receipt
is published last. Each staged candidate must have exactly one hard-link name
before publication. The no-clobber publish link temporarily gives the staged
and final names a link count of two; unlinking the verified staged name must
leave the final entry with a link count of one. An unexpected external hard
link fails closed and its alias is never removed.
Chrome inspects one identity- and content-checked copy of the captured artifact
in a private local temporary directory, so browser file loading does not depend
on UNC or long-path support. The six publication candidates remain on the
evidence volume. Both temporary directories are cleaned without recursively
deleting unknown contents; retained entries include their recovery locations.
Immediately before committing anything, `visual-check` re-resolves and verifies
the complete six-path set. An absent-path claimant, existing-path replacement,
symbolic-link or dangling-link retarget, parent-topology change, hard link, or
indeterminate identity fails closed with `viewer/evidence-path-conflict`. The
claimant and every other final evidence path remain untouched. Cleanup removes
only this run's staged or published entries after rechecking their captured
identities; a changed or unknown entry is preserved.
An existing visual evidence set is replaceable only when a regular
`visual-check` receipt proves ownership of the same artifact and evidence
directory, and its exact sidecar manifest matches every existing contact-sheet
or PNG byte count and SHA-256 digest. A missing, malformed, unknown, mismatched,
or incomplete ownership record never authorizes deletion. Failed and skipped
runs retire prior screenshots/contact sheets only as part of the same verified
transaction when that ownership proof succeeds; otherwise they preserve all
unknown evidence and report `viewer/evidence-path-conflict`.
This rule also applies when Chrome is unavailable or provenance fails before
browser inspection: neither path may blindly delete stale-looking evidence. A
verified owned set may be recoverably retired before publishing a skipped or
failed receipt; unowned evidence remains intact. These outcomes do not invalidate an already
successful deterministic delivery and do not turn a perceptual visual review
into passed or failed. Retry an environmental failure through the supported
command in a browser-capable execution context when practical. Keep the
packaged transport unchanged unless the failure reproduces through that seam in
a capable environment.
`browser-check` applies the same private-snapshot, identity, ownership, and no-clobber rules to its single JSON receipt. Its namespace is separate from `visual-check`, so a browser-only rerun cannot remove capture evidence.
## A new candidate at an existing output path
Browser evidence belongs to exact artifact bytes. After editing a candidate whose previous HTML already has browser evidence, choose a fresh evidence directory before running the next `finalize`; this preserves the old receipts and captures without an avoidable ownership-conflict retry:
```bash
node bin/archify.mjs finalize architecture candidate.json diagram.html --quality showcase --repo-root <root> --out-dir diagram.review-2 --json
node bin/archify.mjs visual-check diagram.html --out-dir diagram.review-2 --summary --require-provenance
```
Keep the requested HTML path stable. Use a new revision directory for each changed candidate, and retain the same directory for retries of unchanged bytes. For a diagram without repository evidence, omit `--repo-root`. Let the commands create their output directory. A prior validation failure that produced no HTML or browser evidence needs no new directory. Never remove unknown evidence to make a retry pass.
## Optional opening
Add `--open` only when the user wants an immediate local preview. It runs after
the verified pair commit has completed, its recovery journal has been removed,
and the delivery lock has been released successfully. It uses one argument-array
OS opener with a five-second bound on macOS and Linux, and a fifteen-second bound
for PowerShell startup on Windows. The receipt records `open.status`; failed or
unavailable launch attempts also include normalized `open.failure` details.
Keep it off for CI, unattended agents, and non-interactive environments.
Failure or unsupported opening does not invalidate delivery; its
status proves only whether the local opener invocation succeeded.
## Last-Good Live Preview
For an active desktop authoring loop only:
```bash
node bin/archify.mjs preview <type> <input>.json <output>.html --quality showcase
```
Preview watches one explicit input on loopback, binds each stable digest to a private snapshot, and advances only after the existing verified delivery pipeline passes. Invalid, half-written, deleted, or superseded input leaves the previous verified revision on screen and on disk. Identical bytes do not rebuild or reload.
The preview runtime ships inside the zero-dependency Skill ZIP and must work without `node_modules`.
Never start it by default. Do not use it for CI, unattended agents, remote sharing, or mobile use. `--no-open` is only for a user who will open the printed local URL or for loop testing. Stop it with Ctrl-C before handoff. The first Ctrl-C drains the active delivery without publishing it; a second Ctrl-C forces shutdown of both delivery processes and HTTP connections, including incomplete requests. Shutdown preserves the last verified artifact and removes only staging files whose ownership can be verified. If delivery is interrupted before its receipt reaches Preview, unconfirmed files and recovery material may remain in the private staging directory; shutdown does not recursively delete unknown contents. Server state, port, source path, diagnostics, error text, and reload tokens must never enter the generated artifact or any export.
## Perceptual review
The automated path ends with the deterministic browser gate and reports
`visual_review: not_requested`. Ordinary generation does not require screenshots
or an image-reading step, including newly authored or repositioned Architecture.
Perceptual review is optional; use it for an explicit request or a concrete visual
investigation. Possible reasons include:
- the compact finalizer includes `visualReviewRecommendation` for crossings or detours (advisory, not a delivery gate);
- the user explicitly requests an aesthetic or visual review;
- a template, renderer, or Viewer change needs visual regression evidence;
- a novel layout or browser diagnostic leaves low confidence;
- the run is selected for sampled audit or dogfood.
For the default standalone desktop viewer, measure 1440×900, 1600×1000, 1920×1080, and 2048×1320. Require `document.documentElement.scrollWidth <= window.innerWidth` at every checked size. Prefer `scrollHeight <= window.innerHeight`; accept page-level vertical scrolling only through the Reader-declared readable exception defined above. At the largest checked viewport, inspect the rendered composition for a conspicuous empty lower band: the main panel and necessary conclusion cards should use the available height as a balanced whole, not collapse into a shallow strip. For unexpected overflow, repair the authored composition by removing only genuinely redundant content or compacting spacing before shrinking nodes, labels, or the main panel. Do not hide overflow, clip content, introduce an internal diagram scroller, or reduce node/label typography to make the measurement pass. Narrow/mobile containment may retain vertical page scrolling.
For an escalation, run `visual-check` on the current finalized artifact, inspect
its contact sheet with a capable image reader or human, and check both endpoint
themes, the default READ view, line crossings/corridors, label masks, node/card
fit, focus/search/passport closure, and export cleanliness. This review is
supplementary and never changes `browser_evidence`. An unconstrained browser
glance can support perceptual review only.
Report one truthful optional-review status:
- `visual_review: not_requested` — no review trigger applies; this is not a visual acceptance claim.
- `visual_review: passed` — only after inspecting the rendered artifact.
- `visual_review: skipped (image reader unavailable)` — a requested or triggered review could not run.
- `visual_review: failed` — with the concrete visible defect.
For an escalated review, use `correction_rounds: 0`, `correction_rounds: 1`, or
`correction_rounds: 2`; never exceed two focused correction rounds. When review
is not requested, use `correction_rounds: 0`. Never report
`visual_review: passed` without inspecting the artifact. If perceptual review
changes the candidate, rerun `finalize` because the previous specification and
artifact receipts are no longer current.
## Handoff receipt
Return:
```text
diagram_type: architecture|workflow|sequence|dataflow|lifecycle
output: /absolute/path/to/file.html
specification_sha256: <receipt value>
artifact_sha256: <receipt value>
validation: 9/9 showcase, 0 errors, 0 warnings
browser_evidence: passed|failed|skipped
visual_review: not_requested|passed|skipped (image reader unavailable)|failed
correction_rounds: 0|1|2
```
Derive `browser_evidence` only from the latest artifact-bound `browser-check`
receipt, normally the stage embedded by `finalize`. Record optional capture or
manual browser work separately with its artifact binding, viewport/theme scope,
and observations; never use it or `visual_review` to overwrite the automated
status.
Opening, preview status, Share Cards, and other viewer exports are not validation claims.
Finalize receipt publication uses the same identity-bound, no-clobber publisher and explicit recovery records described above. The full and summary receipts must be distinct from the candidate, artifact, delivery metadata, and browser receipt. Their targets must be absent or single-link regular files; symlink receipt entries and hardlinked targets fail closed. A later claimant or changed parent stops publication and remains untouched. Default receipt names share the physical artifact namespace and are bounded for the host filename limit. The two receipts are published individually, not as a crash-atomic pair; only a completed passing command is a successful handoff.
@@ -0,0 +1,105 @@
# Repository-backed architecture authoring
Use this reference when a diagram must explain a real repository. The source is
the authority for responsibilities, calls, boundaries, and persistence. The
diagram is complete when the requested meaning is covered and every asserted
fact has supporting source evidence.
## Explore on demand
1. **Freeze identity.** From the target repository, record `git rev-parse
HEAD`, `git remote get-url origin`, and `git status --short`. Remove HTTP(S)
userinfo (including usernames, passwords, and tokens) before recording the
origin or placing it in the candidate. Preserve its transport, port, path and
`.git` suffix; do not rewrite an internal SSH origin as HTTPS. Pin the credential-free URL and
forty-character revision in `meta.repository`. Use `link_mode: "local-only"`
for an SSH origin, unsupported forge, intentionally local-only source links,
or a local fixture whose HTTPS URL is only a repository identity; retain the
URL and revision. Web links require a supported GitHub or Gitee HTTPS origin. If the
worktree is dirty, record the changed paths. Repository evidence is verified
against committed bytes at the pinned revision, not working-tree edits:
inspect a clean checkout at that revision for any cited changed path. Do not
present uncommitted bytes as evidence for `HEAD`; `local-only` does not record
a verifiable snapshot of those bytes.
2. **Map the slice.** Use project instructions, manifests, entry points,
registrations, and deployment configuration to locate candidate runtime
units. Read the entry, configuration, and modules relevant to the request.
Follow imports and call sites
until the requested responsibility reaches its actual input, output, or
side effect. Read a small connected slice instead of scanning the repository
for a convenient label.
3. **Trace ownership.** Derive runtime and I/O relationships from the observed
actor, operation, and target at their call sites; deployment and trust
relationships use the corresponding configuration or enforcement evidence. Distinguish the controller requesting
an operation from the runtime that executes it and the store receiving bytes.
For a file or database edge, the source must identify its actual reader or
writer; a responsibility statement such as “maintains tasks” does not prove
direct I/O. Keep these facts with the source locations while reading, without
a separate planning artifact. Choose which distinctions need separate
nodes using [Composition and meaning](authoring-defaults.md#composition-and-meaning);
discovering an implementation role does not automatically add it to the overview.
A configured provider, an injected adapter, a local stub, and a durable
service are different claims; label the one the source supports.
4. **Record evidence while reading.** Keep exact repository-relative paths and
inclusive line ranges for each component and meaningful relationship. Follow
actual branches, retries, fallbacks, and error handling. A function that is
exported or configured but never called by the normal path is an optional
capability, not a required runtime edge. For a claim about authoritative
state change or control ownership, trace to the actual write or execution
site and the conditions that permit it; an upstream caller alone does not
establish those conditions.
5. **Name uncertainty.** Write unresolved questions beside the claim they
affect: for example, “`writeFile` is called here; durability is unknown.”
Resolve a question by reading the next relevant source range or preserve it
as an explicit unknown. Never turn a label, package description, or config
value into an unobserved service or behavior.
Stop exploring when every requested responsibility, relationship, and boundary
has supporting source entailment and the remaining unknowns cannot change that
coverage. There is no node, edge, citation, view, card, or boundary count to
hit. Do not add a summary step merely to signal completion.
Batch independent relevant files when known. Each additional read should answer
an unresolved question that can change the diagram. Reuse concise facts and
their source ranges already verified in this task; across revisions, recheck
the affected entry points, configuration, dependencies, and evidence.
## Choose an example by structure
Select the main example in the [Type router](../SKILL.md#type-router) before
loading its content, using the request and repository metadata already needed
for source inspection. Selection fits the existing read batch and needs no extra
message, command, or repository-wide scan. For mixed or unclear tasks, use the
requested responsibilities and entry points as they become known in normal
inspection; keep their actual roles. Read another example when a necessary
capability remains unexplained. Examples teach shape, not facts: a library need
not acquire filesystem nodes, and finished showcases still follow the
first-draft automatic-routing rule.
## Author from evidence
Use the mode's complete JSON shape, including repository identity,
components, and connections; every repository-backed component needs supporting source
references, while boundaries or cards are added only when they
answer a real reader question. Let automatic routes and automatic
viewBox sizing work first. Keep the primary path readable, put exception paths
beside their owner, and leave filesystem stores outside a control boundary when
the source shows a separate responsibility.
An existing example teaches field shape, not facts or arbitrary values. It does
not authorize a new boundary kind, a long note, a viewBox size, or a route
control. Consult the specific mode schema and `schemas/common.schema.json`
whether or not the selected example already contains the field; use the
schema's enum, length, identifier, and repository rules. Architecture
boundaries currently use `kind: "region"` or `kind: "security-group"`; source
references use `path`, `line`, and optional `end_line`.
Repository-backed components need concise, truthful `sources` references. Preserve
control ownership when summarizing filesystem I/O: the code that reads or writes
a file owns that action, while a pure in-memory transform receives and returns
values. This fact-check does not require a separate overview node for every helper. Use the existing examples for valid field shape, then replace all
identifiers, wording, source paths, and claims with inspected repository facts.
+12
View File
@@ -0,0 +1,12 @@
# Update awareness
Read this file when a `finalize` or standalone `deliver` receipt has `update.noticeRequired: true`.
Keep one compact line in the final response, in the user's language, with `installedVersion`, `availableVersion`, and the official `releaseNotes` link. Say that the installed Skill has not changed and that the user can ask to snooze or ignore the reminder. If `source` is `cache`, say that a previous check at `checkedAt` found the update. A process message or tool output does not replace this final line.
For `severity: "security"`, label it as a security update without making installation automatic or urgent by default.
You may translate the fixed local `noticeText`. Never quote, summarize, or translate the remote manifest's summary.
When the user explicitly asks to pause or stop this reminder, run `node scripts/check-update.mjs --snooze "<eventKey>"` (seven days) or `--ignore "<eventKey>"` (this exact release only) from the Skill directory with the receipt's `update.eventKey`, then report the returned status. Never run them on your own initiative; `--ack` is a no-op. These commands do not install an update, and a newer release notifies again.
The notice is information, not permission. Keep the installed version unchanged. This workflow never downloads, installs, or executes an update, and silence is never consent.
+35
View File
@@ -0,0 +1,35 @@
# Viewer Runtime reference
Read this only when the user asks for a reader-facing capability. Ordinary generation does not require implementing or re-documenting these features; they are already in the generated HTML.
## Exploration
- Diagram Guide lists current actions and shortcuts.
- Reading Depth starts at READ at the default 100% scale, reveals FULL detail at 175%, and falls back to MAP only below 100%. Focus, route, and semantic interactions reveal their exact facts at any scale.
- Semantic Lens summarizes selected node/relationship kinds without changing authored geometry.
- Intent Trace previews a fine-pointer or keyboard target before committed focus.
- Node Finder searches labels and stable IDs.
- Semantic Passport opens on focus, shows authored upstream/downstream facts, supports a copyable deep link, has an explicit close action, closes on true outside activation and Escape, and never enters canonical export.
- Semantic Radar mirrors the visible viewport and authored graph without becoming a second source of truth.
- Direct Relationship Pin makes a unique compiled relationship operable while preserving the authored line and stable relationship identity. It must fail closed on conflicting source/target/label/ID metadata.
- Route Probe resolves exactly two endpoints over authored directed relationships. It never infers a route from geometry.
## Motion and presentation
`meta.animation: "trace"` enables a finite reader-controlled Live/Still trace. Static is the default. Still, reduced motion, page hiding, print, and canonical export preserve complete static meaning. Presentation Stage changes viewer chrome and framing, never authored geometry. This is not a mobile product feature; narrow layouts get containment only.
## Canonical exports
The export menu can copy/download full-diagram PNG, download JPEG/WebP, download a dual-theme SVG, and record a trace-enabled WebM. Viewer state—Guide, Lens, finder, focus, route, camera, radar, presentation, motion ownership, and temporary overlays—must be removed from canonical export.
### Route Share Card
After a real directed Route Probe resolves, the reader may use **Export → Route Share Card**. It reuses the exact ordered route snapshot and the shared Share Card seam: `format=share-card`, `variant=route`. The isolated clone may use only static `data-share-route-*` decoration. It is download-only, fails closed for stale/unreachable/conflicting routes, and never becomes the canonical artifact.
### Reach Share Card
After a non-empty authored reachability query, the reader may use **Export → Reach Share Card**. It consumes the already resolved upstream/downstream node and edge set without rerunning traversal: `format=share-card`, `variant=reach`. The isolated clone may use only static `data-share-reach-*` decoration. It is download-only. Call it authored reachability—not impact, blast radius, breakage, or runtime causality.
## Truth boundary
Viewer exports are communication assets. They do not replace the checked HTML, the deterministic delivery receipt, or a real visual review. Do not add a hosted service, storage surface, dependency, schema branch, or mobile product surface for these viewer-only capabilities.
+62
View File
@@ -0,0 +1,62 @@
/** Grid placement for architecture IR (#8). Not auto-layout — fixed cell math only. */
export const DEFAULT_GRID = {
mode: 'grid',
origin: [40, 80],
cols: 4,
gapX: 30,
gapY: 40,
cellW: 130,
cellH: 64,
};
export function gridLayout(arch) {
const raw = arch.layout;
if (!raw || raw.mode !== 'grid') return null;
return { ...DEFAULT_GRID, ...raw };
}
export function resolveComponentPos(component, grid) {
if (Array.isArray(component.pos) && component.pos.length === 2) {
return component.pos;
}
if (!grid) return [NaN, NaN];
if (!Number.isInteger(component.row) || !Number.isInteger(component.col)) {
return [NaN, NaN];
}
const [ox, oy] = grid.origin;
const stepX = grid.cellW + grid.gapX;
const stepY = grid.cellH + grid.gapY;
return [ox + component.col * stepX, oy + component.row * stepY];
}
export function validateGridPlacement(arch, grid, problems) {
if (!grid) return;
if (arch.layout !== undefined && arch.layout.mode !== 'grid') {
problems.push('layout.mode must be "grid" when layout is set (free placement omits layout entirely).');
return;
}
const seen = new Map();
for (const c of arch.components ?? []) {
const hasPos = Array.isArray(c.pos) && c.pos.length === 2;
const hasCell = Number.isInteger(c.row) && Number.isInteger(c.col);
if (hasPos) continue; // pos wins; row/col are optional hints only
if (!hasPos && !hasCell) {
problems.push(`Component "${c.id}" needs pos [x,y] or grid row/col when layout.mode is "grid".`);
continue;
}
if (c.row < 0 || c.col < 0) {
problems.push(`Component "${c.id}" row/col must be non-negative integers.`);
continue;
}
if (c.col >= grid.cols) {
problems.push(`Component "${c.id}" col ${c.col} exceeds layout.cols ${grid.cols} (valid: 0..${grid.cols - 1}).`);
}
const key = `${c.row},${c.col}`;
if (seen.has(key)) {
problems.push(`Components "${seen.get(key)}" and "${c.id}" share grid cell row ${c.row} col ${c.col}.`);
} else {
seen.set(key, c.id);
}
}
}
+215
View File
@@ -0,0 +1,215 @@
import { normalizeRoutePoints, rectsOverlap, segmentRectClearanceWithin } from '../shared/geometry.mjs';
import { createSpatialGrid } from '../shared/spatial-grid.mjs';
// A bounded fallback for an unpinned label whose usual position collides.
// It never routes an edge, moves a node, expands the canvas, or rewrites input.
export function placeAutomaticLabels({
labels, routes, components, titles, viewBox, placementBottom = viewBox[1], fallbackRing = true, keepFallbackNearRoute = false,
gridSweep = false,
}) {
const placed = [...labels];
const obstacles = [...components, ...titles];
const segments = routes.flatMap(({ relationIndex, points }) => {
const normalized = normalizeRoutePoints(points);
return normalized.slice(1).map((end, index) => ({ relationIndex, start: normalized[index], end }));
});
const inside = rect => (
rect.x >= 0 && rect.y >= 0
&& rect.x + rect.width <= viewBox[0] && rect.y + rect.height <= viewBox[1]
);
// The mask test asked every segment about every candidate position. Segments
// go into a uniform grid once, and a candidate only asks the cells it covers.
const SEGMENT_CELL = 120;
const segmentGrid = createSpatialGrid(SEGMENT_CELL);
for (const segment of segments) {
const [sx, sy] = segment.start;
const [ex, ey] = segment.end;
segmentGrid.insert({
minX: Math.min(sx, ex), maxX: Math.max(sx, ex),
minY: Math.min(sy, ey), maxY: Math.max(sy, ey),
}, segment);
}
const segmentsNear = (rect, margin) => segmentGrid.query({
minX: rect.x - margin, maxX: rect.x + rect.width + margin,
minY: rect.y - margin, maxY: rect.y + rect.height + margin,
});
const masksRoute = rect => {
for (const segment of segmentsNear(rect, 4)) {
if (segment.relationIndex === rect.relationIndex) continue;
if (segmentRectClearanceWithin(segment, rect, 4) + 0.0001 < 4) return true;
}
return false;
};
const overlapsLabel = (rect, index, gap = 0) => placed.some((other, otherIndex) => (
otherIndex !== index && rectsOverlap(rect, other, gap)
));
const clear = (rect, index) => (
inside(rect) && rect.y + rect.height <= placementBottom
&& !obstacles.some(obstacle => rectsOverlap(rect, obstacle, 2))
&& !overlapsLabel(rect, index, 2) && !masksRoute(rect)
);
const rectAt = (label, lx, ly) => ({
...label, lx, ly, x: lx - label.width / 2, y: ly - 10,
});
// An opted-in grid layout runs parallel lines through shared gaps. Rank
// every position along all of the label's own segments: beside the line
// first, then centred on its own line (the plate interrupts only that
// line), then stepping outward past neighbouring parallels.
const gridCandidates = (label) => {
const fractions = [0.5, 0.25, 0.75, 0.375, 0.625, 0.125, 0.875];
const ranked = [];
// A close parallel of another relationship on one side (a reciprocal
// pair) makes a label on that side read as the neighbour's: prefer the
// far side.
const parallelOnLowSide = (a, b, axis) => {
const across = 1 - axis;
const [low, high] = [Math.min(a[axis], b[axis]), Math.max(a[axis], b[axis])];
let nearest = null;
for (const other of segments) {
if (other.relationIndex === label.relationIndex) continue;
if (Math.abs(other.start[across] - other.end[across]) > 0.0001) continue;
const distance = other.start[across] - a[across];
if (Math.abs(distance) < 0.0001 || Math.abs(distance) > 36) continue;
const overlap = Math.min(high, Math.max(other.start[axis], other.end[axis]))
- Math.max(low, Math.min(other.start[axis], other.end[axis]));
if (overlap <= 0) continue;
if (nearest === null || Math.abs(distance) < Math.abs(nearest)) nearest = distance;
}
return nearest !== null && nearest < 0;
};
for (const { start: a, end: b } of segments.filter(segment => segment.relationIndex === label.relationIndex)) {
if (Math.abs(a[1] - b[1]) < 0.0001 && Math.abs(a[0] - b[0]) >= label.width + 16) {
const [above, below] = parallelOnLowSide(a, b, 0) ? [1, 0] : [0, 1];
for (const fraction of fractions) {
const x = a[0] + (b[0] - a[0]) * fraction;
if (Math.min(Math.abs(x - a[0]), Math.abs(x - b[0])) < label.width / 2 + 6) continue;
ranked.push([above, x, a[1] - 10], [below, x, a[1] + 20], [2, x, a[1] + 3], [3, x, a[1] - 18], [3, x, a[1] + 28]);
}
} else if (Math.abs(a[0] - b[0]) < 0.0001 && Math.abs(a[1] - b[1]) >= label.height + 16) {
const leftFirst = !parallelOnLowSide(a, b, 1);
for (const fraction of fractions) {
const y = a[1] + (b[1] - a[1]) * fraction;
if (Math.min(Math.abs(y - a[1]), Math.abs(y - b[1])) < label.height / 2 + 6) continue;
ranked.push([2, a[0], y + 3]);
[6, 14, 22, 30, 38, 46, 54].forEach((offset, step) => {
const tier = step === 0 ? 0 : step === 1 ? 1 : 2 + step;
const left = [tier + (leftFirst ? 0 : 0.5), a[0] - label.width / 2 - offset, y + 3];
const right = [tier + (leftFirst ? 0.5 : 0), a[0] + label.width / 2 + offset, y + 3];
ranked.push(left, right);
});
}
}
}
return ranked.map((entry, order) => [...entry, order])
.sort((left, right) => left[0] - right[0] || left[3] - right[3])
.map(([, lx, ly]) => [lx, ly]);
};
for (const [index, label] of placed.entries()) {
const relation = label.relation;
if (['labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => relation[key] !== undefined)) continue;
// Match actual defect thresholds before searching; a valid placement is
// not a reason to restyle the diagram. New placements leave extra space.
// The grid ranking already starts from the preferred position, so it
// also re-ranks labels whose default spot is merely valid.
if (!gridSweep && inside(label) && !components.some(component => rectsOverlap(label, component, -2))
&& !titles.some(title => rectsOverlap(label, title))
&& !overlapsLabel(label, index) && !masksRoute(label)) continue;
if (gridSweep) {
// Callers measure the plate one pixel higher than rectAt; test with a
// pixel of slack so the chosen position also passes their clearance.
const replacement = gridCandidates(label).map(([lx, ly]) => rectAt(label, lx, ly))
.find(rect => clear({ ...rect, x: rect.x - 1, y: rect.y - 1, width: rect.width + 2, height: rect.height + 2 }, index));
if (replacement) {
placed[index] = replacement;
continue;
}
}
for (const segment of segments.filter(segment => segment.relationIndex === label.relationIndex)) {
const [a, b] = [segment.start, segment.end];
let candidates = [];
if (Math.abs(a[1] - b[1]) < 0.0001 && Math.abs(a[0] - b[0]) >= label.width + 16) {
candidates = [0.5, 0.25, 0.75, 0.125, 0.875].flatMap(fraction => {
const x = a[0] + (b[0] - a[0]) * fraction;
if (Math.min(Math.abs(x - a[0]), Math.abs(x - b[0])) < 8) return [];
return [
[x, a[1] - 10],
[x, a[1] + 20],
[x, a[1] - 18],
[x, a[1] + 28],
];
});
} else if (Math.abs(a[0] - b[0]) < 0.0001 && Math.abs(a[1] - b[1]) >= label.height + 16) {
candidates = [0.5, 0.25, 0.75, 0.125, 0.875].flatMap(fraction => {
const y = a[1] + (b[1] - a[1]) * fraction;
if (Math.min(Math.abs(y - a[1]), Math.abs(y - b[1])) < 8) return [];
return [
[a[0] - label.width / 2 - 6, y + 3],
[a[0] + label.width / 2 + 6, y + 3],
[a[0] - label.width / 2 - 14, y + 3],
[a[0] + label.width / 2 + 14, y + 3],
];
});
}
const replacement = candidates.map(([lx, ly]) => rectAt(label, lx, ly))
.find(rect => clear(rect, index));
if (replacement) {
placed[index] = replacement;
break;
}
}
if (placed[index] !== label || !fallbackRing) continue;
// Dense but valid topologies can leave every point directly beside the
// relationship occupied by another route. Search a small deterministic
// ring around the current anchor and the relationship's segment centres.
// A collision-free island above a node is not a readable edge label.
// Architecture opts into keeping the mask within two label heights of
// its own route; shared callers retain their existing policy. If no nearby
// slot fits, retain the collision so validation can request more space.
const ownSegments = segments.filter(segment => segment.relationIndex === label.relationIndex);
const baseAnchors = [
[label.lx, label.ly],
...ownSegments.map(segment => [
(segment.start[0] + segment.end[0]) / 2,
(segment.start[1] + segment.end[1]) / 2,
]),
];
const horizontalStep = label.width / 2 + 12;
const ringOffsets = [
[0, -28], [0, 38],
[-horizontalStep, -28], [horizontalStep, -28],
[-horizontalStep, 38], [horizontalStep, 38],
[-(label.width + 20), -52], [label.width + 20, -52],
[-(label.width + 20), 62], [label.width + 20, 62],
[-(label.width + 20), -76], [label.width + 20, -76],
[-(label.width + 20), 86], [label.width + 20, 86],
];
const fallback = baseAnchors.flatMap(([baseX, baseY]) => (
ringOffsets.map(([dx, dy]) => rectAt(label, baseX + dx, baseY + dy))
)).find(rect => clear(rect, index) && (!keepFallbackNearRoute || ownSegments.some(segment => (
segmentRectClearanceWithin(segment, rect, label.height * 2) <= label.height * 2
))));
if (fallback) placed[index] = fallback;
}
return placed;
}
// The rect a single unpinned label would occupy given only its own route and
// the nodes: what the planner reserves before the remaining routes are laid.
// Only a placement beside the route itself is worth reserving; a label that
// would already need the fallback ring is left to the final placement pass.
export function reservedLabelRect({
label, points, routes = [], labels = [], components, viewBox = [Infinity, Infinity], placementBottom = Infinity,
}) {
const [rect] = placeAutomaticLabels({
labels: [{ ...label, relationIndex: -1 }, ...labels.map(other => ({ ...other, relationIndex: -2 }))],
routes: [{ relationIndex: -1, points }, ...routes],
components,
titles: [],
viewBox,
placementBottom,
fallbackRing: false,
});
return components.some(component => rectsOverlap(rect, component, -2)) ? null : rect;
}
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff. Show more