Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0090bc15f | ||
|
|
f5c1b7430a | ||
|
|
e316afa51d | ||
|
|
87bfe50f0e | ||
|
|
4c643547b6 | ||
|
|
6a54aa6c50 | ||
|
|
05c8459d12 | ||
|
|
991bf3fe65 | ||
|
|
90b606f865 | ||
|
|
6808a681b7 | ||
|
|
9d0cde7252 | ||
|
|
532e30672e | ||
|
|
71046973ae | ||
|
|
efe321ca2f | ||
|
|
5a6b109a89 | ||
|
|
25f1b79042 | ||
|
|
c0d08df82b | ||
|
|
32b2edaa49 | ||
|
|
67e1e0a74f | ||
|
|
166e0d40ac |
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 tt-a1i (Archify)
|
||||
Copyright (c) 2025 Cocoon AI (original "architecture-diagram-generator")
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
name: archify
|
||||
description: Create polished, validated architecture, workflow, sequence, data-flow, and lifecycle/state diagrams as explorable standalone HTML with inline SVG, dark/light themes, optional trace motion, and PNG/JPEG/WebP/SVG/WebM export. Accept plain-language requirements or pasted Mermaid flowchart, sequenceDiagram, and stateDiagram input; inspect repository evidence when the diagram must reflect real code. Use when the user asks to visualize system architecture, infrastructure, cloud/security/network topology, technical workflows, API call sequences, request lifecycles, data pipelines, ETL/ELT, data lineage, state machines, or to convert/beautify Mermaid.
|
||||
license: MIT
|
||||
metadata:
|
||||
version: '2.16'
|
||||
author: tt-a1i
|
||||
based_on: Cocoon-AI/architecture-diagram-generator (MIT, v1.0)
|
||||
---
|
||||
|
||||
# Archify
|
||||
|
||||
Create a self-contained, interactive HTML diagram from a small typed JSON specification. Static output is the default; enable motion only when the user asks for a demo or presentation.
|
||||
|
||||
## Fast authoring path
|
||||
|
||||
Use this bounded path for ordinary generation. Do not read the optional Viewer Runtime reference unless the user asks about those features.
|
||||
|
||||
1. Choose `architecture`, `workflow`, `sequence`, `dataflow`, or `lifecycle` from the question.
|
||||
2. Read one matching schema in `schemas/`, `schemas/common.schema.json`, and one matching JSON example in `examples/`. Read only those files. Fresh authorship means new stable IDs, domain wording, and layout; use the example for field shape, not facts. New workflow sources use `schema_version: 2` and its readable layout contract; keep `schema_version: 1` only when preserving an existing workflow's fixed geometry. When real product identity matters, query `node bin/archify.mjs brands "<name>" --json`; read `references/brand-marks.md` only for an unknown brand with a user-provided URL.
|
||||
3. Artifact first: the next tool action must write the candidate. Write the candidate before inspecting renderer internals. Do not plan exact coordinates in prose. Start with one clear main path, short side branches, sparse labels, and at most 12 primary nodes. Set `meta.quality_profile` to `"showcase"` unless the user explicitly requests a dense `standard` map. Start with automatic routes and labels. Do not add `via`, `channelX`, `channelY`, or `labelAt` before a diagnostic calls for one; apply at most one diagnosed geometry control per repair.
|
||||
4. Validate after every candidate edit and immediately before handoff:
|
||||
|
||||
```bash
|
||||
node bin/archify.mjs validate <type> <candidate.json> --quality showcase --json
|
||||
```
|
||||
|
||||
A receipt with only 4 artifact checks is basic validation, never showcase acceptance. A showcase pass must report all 9 artifact checks with 0 composition errors and 0 warnings. If the candidate omits or misspells the exact `meta.quality_profile` field, fix it before geometry. For a workflow v2 geometry diagnosis, run `node bin/archify.mjs validate workflow <candidate.json> --layout-json` and use the stable compiler receipt; solver internals are not authoring controls. A passing final validation freezes the candidate: never edit it afterward.
|
||||
|
||||
5. For a delivered HTML, `deliver` is the final acceptance command:
|
||||
|
||||
```bash
|
||||
node bin/archify.mjs deliver <type> <candidate.json> <output.html> --quality showcase --json
|
||||
```
|
||||
|
||||
A non-zero exit can never be described as success. A failed delivery preserves any previous output, so do not run `visual-check` on that path: it would inspect the stale last-good artifact, not the failed candidate. If validation fails, change only the diagnosed `subject`, verify `evidence`, choose from `supportedFixes`, and rerun. Continue focused correction while the objective error count reaches a new minimum. If two consecutive rounds do not improve that best count, stop and report the unresolved diagnostics truthfully.
|
||||
|
||||
## Update awareness
|
||||
|
||||
After the first candidate exists, run the packaged checker `scripts/check-update.mjs` once with Node and continue the requested workflow. If the command cannot run, continue without mentioning the check.
|
||||
|
||||
- For `silent`, continue without mentioning the update check.
|
||||
- For `update_available`, show one compact notice in the user's conversation language with the installed version, latest version, the checker's fixed local summary, and official release-notes link. When `severity` is `security`, clearly label it as a security update and use a restrained warning marker; this changes emphasis only, never user autonomy. Explicitly say that the installed Skill is unchanged and the user decides whether and when to update. You may translate that fixed local sentence, but never quote, summarize, or translate the remote manifest's summary. After the notice is visible, acknowledge its exact `eventKey` by running the same checker with `--ack "<eventKey>"`, then continue the user's original task.
|
||||
|
||||
The notice is information, not permission. Keep the installed version unchanged; this v0.1 workflow never downloads, installs, or executes an update, and silence is never consent.
|
||||
|
||||
Do not read `renderers/shared/geometry.mjs`, renderer source, validator source, tests, or benchmarks before the first candidate. Inspect implementation only for an unsupported internal diagnostic or after two focused repairs fail.
|
||||
|
||||
Workflow note: use schema v2 for new workflows; preserve schema v1 when an
|
||||
existing source needs fixed legacy geometry. Keep semantic edge labels and act
|
||||
on the compiler diagnostic. The canonical layout, pin, migration, and receipt
|
||||
contract is in [`renderers/workflow/README.md`](renderers/workflow/README.md#layout-contracts).
|
||||
|
||||
Lifecycle note: phase columns `0..4` occupy the main rail; event/terminal column `N` in `0..2` aligns exactly beneath main column `N + 2`. A recoverable state uses `type: "failure"` plus a real transition back to the active state.
|
||||
|
||||
## Type router
|
||||
|
||||
| Type | Use for |
|
||||
| -------------- | --------------------------------------------------------------- |
|
||||
| `architecture` | Components, services, cloud/security boundaries, infrastructure |
|
||||
| `workflow` | Processes, approval gates, tool calls, runbooks, CI/CD |
|
||||
| `sequence` | API call chains, request lifecycles, async traces, returns |
|
||||
| `dataflow` | Pipelines, ETL/ELT, lineage, governance, consumers |
|
||||
| `lifecycle` | State/status transitions, retries, waiting and terminal states |
|
||||
|
||||
When ambiguous, run `node bin/archify.mjs guide "<scenario>" --json`. Scenario proof examples are structural references, not facts to copy.
|
||||
|
||||
## Mermaid input
|
||||
|
||||
Read Mermaid for topology and meaning, then author fresh Archify JSON; do not mechanically render Mermaid styling.
|
||||
|
||||
- `flowchart` / `graph` → `workflow`, or `architecture` for a component map.
|
||||
- `sequenceDiagram` → `sequence`; participants become semantic participants and arrows become messages.
|
||||
- `stateDiagram` → `lifecycle`; states and transitions retain meaning, not Mermaid style.
|
||||
|
||||
## Authoring invariants
|
||||
|
||||
- One obvious main path; side branches leave the nearest main-path node. Remove low-value edges before adding routing controls.
|
||||
- Omit `meta.visual_preset` by default so every diagram opens in `classic`, regardless of whether its resolved color mode is light or dark. Color mode and visual preset are independent: switching Light / Dark must preserve the current preset. Set `signal-flow`, `blueprint`, or `editorial` only when the user explicitly requests that visual style.
|
||||
- Omit `meta.subtitle` by default. Never invent a subtitle that restates the title, nodes, or cards; include one short supporting line only when the user explicitly asks for it.
|
||||
- Treat the standalone desktop viewer as a first-screen artifact by default, not a shallow strip. Generate one responsive artifact for laptops and external displays—never device-specific HTML or alternate topology. The viewer may adapt only the outer reading width from the live viewport height; it must preserve the authored SVG/viewBox, proportions, semantic geometry, and normal document flow. On a wide or tall desktop, use enough authored vertical rhythm that the diagram panel and its necessary conclusion cards occupy the screen as a balanced whole; runtime scaling cannot repair an over-compressed Y layout or an undersized explicit `meta.viewBox`. Before handoff, open the real HTML at 1440×900, 1600×1000, and 1920×1080; additionally check 2048×1320 whenever the composition is intended for a large desktop display. Require `document.documentElement.scrollWidth <= window.innerWidth` and `scrollHeight <= window.innerHeight` at every checked size, while visually checking that the diagram remains comfortably readable and vertically balanced at the largest checked viewport. Repair overflow by removing only genuinely redundant content or compacting spacing before shrinking nodes, labels, or the main panel. If the largest viewport still has a conspicuous empty lower band at the viewer's width cap, redistribute authored Y positions and increase the viewBox height proportionally; do not add filler copy or decorative cards. Never counterfeit a pass with `overflow: hidden`, clipped content, an internal diagram scroller, stretched SVG height, or smaller typography. Narrow/mobile layouts may scroll vertically when containment requires it.
|
||||
- Omit `meta.legend` for the truthful `auto` default. When needed, use only `mode: auto|all|hidden` and renderer-supported `entries.<kind>.label|visible`; labels never change semantics.
|
||||
- Choose one primary authored language from an explicit user choice; otherwise follow the request or conversation's dominant language. `meta.locale` controls only renderer-owned Viewer UI: use `"en"` or `"zh-CN"` for the corresponding supported primary language. For every other language, omit `meta.locale` and explicitly disclose that the fixed Viewer UI and `<html lang>` fall back to English. The renderer never translates authored content. See `references/authoring-contract.md` for details.
|
||||
- Preserve exact product names, code identifiers, commands, protocols, API paths, and environment names. They may remain English inside localized copy, but never justify leaving the surrounding explanatory prose in another language.
|
||||
- Brand identity is optional and explicit. Put a canonical built-in ID in `brand` when the node names that real product. If no preset matches and the user supplied the official HTTP(S) URL, first run `node bin/archify.mjs brands capture "<url>" --json`, then author the returned digest-pinned `brand` object. Render and validate never perform an unpinned capture. Otherwise omit `brand`. Never infer a brand from a vague role such as "database", and never let a badge replace the semantic `type`, label, or relationship facts.
|
||||
- For sequence diagrams, omit `meta.column_fit` for the stable `fixed` layout. Set it to `"spread"` when a wide viewBox would otherwise leave unused horizontal space or when meaningful participant labels do not fit the fixed boxes; do not shorten semantic labels before trying `spread`.
|
||||
- Component types are `frontend`, `backend`, `database`, `cloud`, `security`, `messagebus`, and `external`; variants are `default`, `emphasis`, `security`, and `dashed`.
|
||||
- Relationship labels are semantic data. When one collides, move the label, adjust the route or spacing, then shorten the wording while preserving meaning. Omit only wording that is already fully implied by both endpoints and contains no protocol, action, direction, synchronous/asynchronous behavior, or cross-boundary mechanism. Preserve every meaningful label; deleting it is not a geometry 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 geometry repair.
|
||||
- Omit `meta.engineering_profile` by default. Region, cluster, and security boundary wording do not by themselves enable it. 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, must not remove the engineering profile merely to pass validation; repair the facts or report the diagnostics truthfully.
|
||||
- Spacing means clear gap, not center distance. For a relationship label, clear gap must exceed its measured mask width; follow the label-preserving repair order.
|
||||
- Automatic routes own their endpoint sides. A side is a direction contract: the first and final segment must leave/enter perpendicular to that side.
|
||||
- Automatic Port Spread is a default renderer behavior for architecture, workflow, data-flow, and lifecycle. It skips single relationships and explicit `via`, `channelX`, `channelY`, `labelAt`, or non-`auto` routes. Near parallel ports use an outside bridge so automatic routing cannot create a sub-8px segment or sub-16px interior turn. Architecture separately keeps unobstructed facing automatic ports (`left`/`right` or `top`/`bottom`) on one shared axis when their offset is under 16px and both ports retain corner clearance. If exactly one endpoint was spread, only the unshared endpoint may move onto that axis; if both endpoints were spread, keep the outside bridge so competing ports remain distinct.
|
||||
- Never accept an edge crossing an unrelated opaque node, an ambiguous shared corridor, or a relationship label masking another route.
|
||||
|
||||
Read `references/authoring-contract.md` only when you need field enums, spacing math, geometry repair rules, repository evidence, or mode-specific placement.
|
||||
|
||||
## Delivery
|
||||
|
||||
Use `validate` during repair and `deliver` once for final acceptance. Delivery freezes the exact specification bytes into a private same-directory snapshot, renders and checks that snapshot, atomically commits the HTML, and reports SHA-256 plus byte counts for both specification and artifact.
|
||||
|
||||
After delivery, collect bounded desktop evidence without modifying or rerendering the trusted HTML:
|
||||
|
||||
```bash
|
||||
node bin/archify.mjs visual-check <output.html> --json
|
||||
```
|
||||
|
||||
`visual-check` measures containment at 1440×900, 1600×1000, 1920×1080, and 2048×1320; captures light/dark screenshots at the smallest and largest sizes; and writes a relative-path contact sheet plus JSON sidecars beside the artifact. Its automated receipt always reports `visualReview: "pending"`: screenshots are evidence for inspection, never an automatic polish claim. Exit 0 means containment and captures passed, 1 means overflow or capture failure, and 2 means Chrome/Chromium was unavailable and the receipt is `skipped`. The command never changes the delivered HTML.
|
||||
|
||||
Add `--open` only when the user wants an immediate local preview. For an active desktop authoring loop, the optional command is:
|
||||
|
||||
```bash
|
||||
node bin/archify.mjs preview <type> <input>.json <output>.html --quality showcase
|
||||
```
|
||||
|
||||
Never start preview by default. Read `references/delivery-contract.md` when using preview, repository evidence, export receipts, visual review, or post-commit opening.
|
||||
|
||||
## Optional viewer capabilities
|
||||
|
||||
Generated HTML already contains theme switching, pan/zoom, search, focus, relationship tracing, semantic views, presentation, and truthful exports. These are reader capabilities, not extra authoring work. `meta.animation: "trace"` is opt-in; `meta.views` is optional and should contain at most five curated chapters.
|
||||
|
||||
Read `references/viewer-runtime.md` only when the user explicitly asks for Share Cards, Route/Reach cards, motion, guided stories, deep links, presentation, search/focus, or another Viewer Runtime feature.
|
||||
|
||||
## Setup and fallback
|
||||
|
||||
No install is required inside the skill package. Verify with:
|
||||
|
||||
```bash
|
||||
node bin/archify.mjs doctor
|
||||
node bin/archify.mjs demo <output-directory>
|
||||
```
|
||||
|
||||
When shell access is unavailable, hand-place architecture SVG into `assets/template.html`, use CSS semantic classes rather than inline colors, and follow the visual review contract in `references/delivery-contract.md`.
|
||||
|
||||
## Output
|
||||
|
||||
Return the checked HTML path, diagram type, validation summary, specification/artifact receipt, and truthful visual-review status. Do not claim success for a non-zero command or claim visual inspection you did not perform.
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+1988
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
const OPENERS = {
|
||||
darwin: {
|
||||
command: 'open',
|
||||
method: 'open',
|
||||
args: (target) => [target],
|
||||
},
|
||||
linux: {
|
||||
command: 'xdg-open',
|
||||
method: 'xdg-open',
|
||||
args: (target) => [target],
|
||||
},
|
||||
win32: {
|
||||
command: 'powershell.exe',
|
||||
method: 'powershell',
|
||||
// Keep the command constant and pass the target through PowerShell's
|
||||
// argument array. Paths are never interpolated into executable source.
|
||||
args: (target) => [
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
'Start-Process -FilePath $args[0]',
|
||||
target,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
let result;
|
||||
try {
|
||||
result = spawn(opener.command, opener.args(target), {
|
||||
encoding: 'utf8',
|
||||
shell: false,
|
||||
stdio: 'ignore',
|
||||
timeout: options.timeoutMs || 5000,
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
result = { error: new Error('opener threw') };
|
||||
}
|
||||
|
||||
let status = 'opened';
|
||||
if (result?.error?.code === 'ENOENT') status = 'unsupported';
|
||||
else if (result?.error || result?.signal || result?.status !== 0) status = 'failed';
|
||||
|
||||
return {
|
||||
requested: true,
|
||||
status,
|
||||
target,
|
||||
method: opener.method,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
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';
|
||||
|
||||
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']);
|
||||
|
||||
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' && source.meta.output) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
const stagingDirectory = fs.mkdtempSync(path.join(outputDirectory, '.archify-preview-'));
|
||||
|
||||
let port = 0;
|
||||
let watcher;
|
||||
let debounceTimer;
|
||||
let pollTimer;
|
||||
let stopGraceTimer;
|
||||
let stopKillTimer;
|
||||
let child;
|
||||
let stopping = 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 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');
|
||||
});
|
||||
|
||||
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 {}
|
||||
fs.rmSync(stagingDirectory, { recursive: true, force: true });
|
||||
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 {
|
||||
fs.rmSync(stagingDirectory, { recursive: true, force: true });
|
||||
} 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 (!stopping) {
|
||||
stopping = true;
|
||||
clearTimeout(debounceTimer);
|
||||
clearInterval(pollTimer);
|
||||
watcher?.close();
|
||||
closeServer();
|
||||
}
|
||||
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',
|
||||
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) {
|
||||
let candidate;
|
||||
try {
|
||||
candidate = fs.readFileSync(candidatePath);
|
||||
const digest = sha256(candidate);
|
||||
if (digest !== receipt?.artifact?.sha256) {
|
||||
throw new Error('Verified candidate bytes do not match the delivery receipt.');
|
||||
}
|
||||
resolveOutputPath(outputRequest);
|
||||
const sameArtifact = state.lastVerified?.sha256 === digest;
|
||||
let outputMatches = false;
|
||||
if (sameArtifact) {
|
||||
try { outputMatches = sha256(fs.readFileSync(outputPath)) === digest; } catch {}
|
||||
}
|
||||
const currentSource = sourceDigest(inputPath);
|
||||
if (currentSource.hash !== generationHash) {
|
||||
return { committed: false, supersededBy: currentSource };
|
||||
}
|
||||
if (!sameArtifact || !outputMatches) fs.renameSync(candidatePath, outputPath);
|
||||
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 };
|
||||
} catch (error) {
|
||||
publishFailure({ stage: 'commit', error: `Could not publish the verified preview: ${error.message}` }, '', '', candidatePath);
|
||||
return { committed: false, supersededBy: null };
|
||||
}
|
||||
}
|
||||
|
||||
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`);
|
||||
if (digest.bytes !== null) {
|
||||
try {
|
||||
fs.writeFileSync(snapshotPath, digest.bytes, { flag: 'wx', mode: 0o600 });
|
||||
} 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;
|
||||
child = null;
|
||||
clearTimeout(stopGraceTimer);
|
||||
clearTimeout(stopKillTimer);
|
||||
stopGraceTimer = undefined;
|
||||
stopKillTimer = undefined;
|
||||
if (!stopping && !stale && code === 0 && receipt?.ok) {
|
||||
({ supersededBy } = commitCandidate(candidatePath, receipt, generationHash));
|
||||
} else if (!stopping && !stale) {
|
||||
publishFailure(receipt, stdout, stderr, candidatePath, snapshotPath);
|
||||
}
|
||||
try { fs.rmSync(candidatePath, { force: true }); } catch {}
|
||||
try { fs.rmSync(snapshotPath, { force: true }); } catch {}
|
||||
|
||||
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 {
|
||||
watcher = fs.watch(path.dirname(inputPath), (event, filename) => {
|
||||
if (!filename || filename.toString() === path.basename(inputPath)) observeSource();
|
||||
});
|
||||
} 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}). Open it manually: ${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);
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import {
|
||||
DESKTOP_READABILITY_VIEWPORT,
|
||||
MIN_PROJECTED_NODE_TEXT_PX,
|
||||
} from '../renderers/shared/desktop-readability.mjs';
|
||||
|
||||
export const VISUAL_CHECK_VIEWPORTS = Object.freeze([
|
||||
DESKTOP_READABILITY_VIEWPORT,
|
||||
Object.freeze({ width: 1600, height: 1000 }),
|
||||
Object.freeze({ width: 1920, height: 1080 }),
|
||||
Object.freeze({ width: 2048, height: 1320 }),
|
||||
]);
|
||||
|
||||
const CAPTURE_VIEWPORTS = Object.freeze([
|
||||
VISUAL_CHECK_VIEWPORTS[0],
|
||||
VISUAL_CHECK_VIEWPORTS[VISUAL_CHECK_VIEWPORTS.length - 1],
|
||||
]);
|
||||
const THEMES = Object.freeze(['light', 'dark']);
|
||||
const EXIT = Object.freeze({ pass: 0, fail: 1, skipped: 2 });
|
||||
export const CHROME_NO_SANDBOX_ENV = 'ARCHIFY_CHROME_NO_SANDBOX';
|
||||
|
||||
function sha256(buffer) {
|
||||
return createHash('sha256').update(buffer).digest('hex');
|
||||
}
|
||||
|
||||
function htmlEscape(value) {
|
||||
return String(value).replace(/[&<>"']/g, (char) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
})[char]);
|
||||
}
|
||||
|
||||
function safeUnlink(file) {
|
||||
try {
|
||||
fs.rmSync(file, { force: true });
|
||||
} catch {
|
||||
// A stale optional sidecar must never make the delivered HTML mutable.
|
||||
}
|
||||
}
|
||||
|
||||
function writeAtomic(file, contents) {
|
||||
const temporary = `${file}.tmp-${process.pid}`;
|
||||
try {
|
||||
fs.writeFileSync(temporary, contents, { flag: 'w' });
|
||||
fs.renameSync(temporary, file);
|
||||
} finally {
|
||||
safeUnlink(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
function screenshotKey(width, height, theme) {
|
||||
return `${width}x${height}:${theme}`;
|
||||
}
|
||||
|
||||
export function sidecarPaths(artifactPath) {
|
||||
const artifact = path.resolve(artifactPath);
|
||||
const stem = artifact.replace(/\.html?$/i, '');
|
||||
const base = `${stem}.visual-check`;
|
||||
const screenshots = CAPTURE_VIEWPORTS.flatMap(({ width, height }) => THEMES.map((theme) => ({
|
||||
width,
|
||||
height,
|
||||
theme,
|
||||
path: `${base}.${width}x${height}.${theme}.png`,
|
||||
})));
|
||||
return {
|
||||
base,
|
||||
receipt: `${base}.json`,
|
||||
contactSheet: `${base}.html`,
|
||||
screenshots,
|
||||
};
|
||||
}
|
||||
|
||||
function cleanupCaptureSidecars(paths) {
|
||||
safeUnlink(paths.contactSheet);
|
||||
for (const screenshot of paths.screenshots) safeUnlink(screenshot.path);
|
||||
}
|
||||
|
||||
function executable(file, platform = process.platform) {
|
||||
if (!file) return null;
|
||||
try {
|
||||
fs.accessSync(file, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
|
||||
return path.resolve(file);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findOnPath(command, env, platform) {
|
||||
const directories = String(env.PATH || '').split(path.delimiter).filter(Boolean);
|
||||
const extensions = platform === 'win32'
|
||||
? String(env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean)
|
||||
: [''];
|
||||
for (const directory of directories) {
|
||||
for (const extension of extensions) {
|
||||
const candidate = path.join(directory, `${command}${extension}`);
|
||||
const resolved = executable(candidate, platform);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findChrome({ env = process.env, platform = process.platform } = {}) {
|
||||
if (Object.prototype.hasOwnProperty.call(env, 'ARCHIFY_CHROME')) {
|
||||
return executable(env.ARCHIFY_CHROME, platform);
|
||||
}
|
||||
|
||||
const fixed = [];
|
||||
const commands = [];
|
||||
if (platform === 'darwin') {
|
||||
fixed.push(
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
);
|
||||
} else if (platform === 'win32') {
|
||||
for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].filter(Boolean)) {
|
||||
fixed.push(
|
||||
path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
path.join(root, 'Chromium', 'Application', 'chrome.exe'),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
commands.push('google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser');
|
||||
}
|
||||
|
||||
for (const candidate of fixed) {
|
||||
const resolved = executable(candidate, platform);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
for (const command of commands) {
|
||||
const resolved = findOnPath(command, env, platform);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
class PipeCdp {
|
||||
constructor(child, { failureDetails = () => '' } = {}) {
|
||||
this.child = child;
|
||||
this.failureDetails = failureDetails;
|
||||
this.nextId = 1;
|
||||
this.buffer = '';
|
||||
this.pending = new Map();
|
||||
this.waiters = [];
|
||||
this.writePipe = child.stdio[3];
|
||||
this.readPipe = child.stdio[4];
|
||||
this.readPipe.setEncoding('utf8');
|
||||
this.readPipe.on('data', (chunk) => this.consume(chunk));
|
||||
this.writePipe.on('error', (error) => this.failAll(this.failure('write pipe', error)));
|
||||
this.readPipe.on('error', (error) => this.failAll(this.failure('read pipe', error)));
|
||||
child.once('error', (error) => this.failAll(this.failure('process launch', error)));
|
||||
child.once('close', (code, signal) => {
|
||||
const ending = signal ? `signal ${signal}` : `exit code ${code}`;
|
||||
this.failAll(this.failure('process exit', new Error(`Chrome closed with ${ending}`)));
|
||||
});
|
||||
}
|
||||
|
||||
failure(stage, error) {
|
||||
const code = error?.code ? ` [${error.code}]` : '';
|
||||
const details = this.failureDetails();
|
||||
return new Error([
|
||||
`Chrome DevTools ${stage} failed: ${error?.message || String(error)}${code}`,
|
||||
details,
|
||||
].filter(Boolean).join('\n'));
|
||||
}
|
||||
|
||||
consume(chunk) {
|
||||
this.buffer += chunk;
|
||||
let boundary;
|
||||
while ((boundary = this.buffer.indexOf('\0')) >= 0) {
|
||||
const raw = this.buffer.slice(0, boundary);
|
||||
this.buffer = this.buffer.slice(boundary + 1);
|
||||
if (!raw) continue;
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
this.failAll(new Error(`Chrome DevTools returned invalid JSON: ${error.message}`));
|
||||
continue;
|
||||
}
|
||||
if (message.id) {
|
||||
const pending = this.pending.get(message.id);
|
||||
if (!pending) continue;
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(message.id);
|
||||
if (message.error) pending.reject(new Error(`${pending.method}: ${message.error.message}`));
|
||||
else pending.resolve(message.result || {});
|
||||
continue;
|
||||
}
|
||||
for (const waiter of [...this.waiters]) {
|
||||
if (waiter.method !== message.method) continue;
|
||||
if (waiter.sessionId && waiter.sessionId !== message.sessionId) continue;
|
||||
clearTimeout(waiter.timer);
|
||||
this.waiters.splice(this.waiters.indexOf(waiter), 1);
|
||||
waiter.resolve(message.params || {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
send(method, params = {}, sessionId = undefined, timeoutMs = 15000) {
|
||||
const id = this.nextId++;
|
||||
const message = { id, method, params };
|
||||
if (sessionId) message.sessionId = sessionId;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`${method}: timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { method, resolve, reject, timer });
|
||||
try {
|
||||
this.writePipe.write(`${JSON.stringify(message)}\0`, (error) => {
|
||||
if (error) this.failAll(this.failure('write pipe', error));
|
||||
});
|
||||
} catch (error) {
|
||||
this.failAll(this.failure('write pipe', error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
waitFor(method, sessionId, timeoutMs = 15000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const waiter = { method, sessionId, resolve, reject, timer: null };
|
||||
waiter.timer = setTimeout(() => {
|
||||
this.waiters.splice(this.waiters.indexOf(waiter), 1);
|
||||
reject(new Error(`${method}: event timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
this.waiters.push(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
failAll(error) {
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(error);
|
||||
}
|
||||
for (const waiter of this.waiters) {
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
this.waiters = [];
|
||||
}
|
||||
}
|
||||
|
||||
export function chromeVisualBrowserArgs(profileRoot, {
|
||||
env = process.env,
|
||||
getuid = typeof process.getuid === 'function' ? () => process.getuid() : null,
|
||||
} = {}) {
|
||||
const args = [
|
||||
'--headless=new',
|
||||
'--remote-debugging-pipe',
|
||||
'--disable-gpu',
|
||||
'--hide-scrollbars',
|
||||
'--disable-background-networking',
|
||||
'--disable-component-update',
|
||||
'--disable-default-apps',
|
||||
'--disable-sync',
|
||||
'--metrics-recording-only',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--force-device-scale-factor=1',
|
||||
`--user-data-dir=${profileRoot}`,
|
||||
'about:blank',
|
||||
];
|
||||
const rootUser = typeof getuid === 'function' && getuid() === 0;
|
||||
const sandboxOptOut = env?.[CHROME_NO_SANDBOX_ENV] === '1';
|
||||
if (rootUser || sandboxOptOut) args.unshift('--no-sandbox');
|
||||
return args;
|
||||
}
|
||||
|
||||
async function evaluate(cdp, sessionId, expression, awaitPromise = false) {
|
||||
const response = await cdp.send('Runtime.evaluate', {
|
||||
expression,
|
||||
awaitPromise,
|
||||
returnByValue: true,
|
||||
}, sessionId);
|
||||
if (response.exceptionDetails) {
|
||||
throw new Error(response.exceptionDetails.exception?.description
|
||||
|| response.exceptionDetails.text
|
||||
|| 'Runtime.evaluate failed');
|
||||
}
|
||||
return response.result?.value;
|
||||
}
|
||||
|
||||
export class ChromeVisualBrowser {
|
||||
constructor(chromePath, {
|
||||
env = process.env,
|
||||
getuid = typeof process.getuid === 'function' ? () => process.getuid() : null,
|
||||
spawnImpl = spawn,
|
||||
} = {}) {
|
||||
this.profileRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-visual-check-profile-'));
|
||||
this.stderr = '';
|
||||
const args = chromeVisualBrowserArgs(this.profileRoot, { env, getuid });
|
||||
this.child = spawnImpl(chromePath, args, { stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'] });
|
||||
this.child.stderr.setEncoding('utf8');
|
||||
this.child.stderr.on('data', (chunk) => {
|
||||
this.stderr = `${this.stderr}${chunk}`.slice(-8000);
|
||||
});
|
||||
this.child.stderr.on('error', (error) => {
|
||||
this.stderr = `${this.stderr}\nChrome stderr stream failed: ${error.message}`.trim().slice(-8000);
|
||||
});
|
||||
this.cdp = new PipeCdp(this.child, {
|
||||
failureDetails: () => {
|
||||
const exit = this.child.signalCode
|
||||
? `signal ${this.child.signalCode}`
|
||||
: this.child.exitCode == null ? 'still running' : `exit code ${this.child.exitCode}`;
|
||||
const stderr = this.stderr.trim();
|
||||
return [
|
||||
`Chrome process: ${exit}.`,
|
||||
stderr ? `Chrome stderr:\n${stderr}` : '',
|
||||
].filter(Boolean).join('\n');
|
||||
},
|
||||
});
|
||||
this.sessionPromise = this.attach();
|
||||
}
|
||||
|
||||
async attach() {
|
||||
const targets = await this.cdp.send('Target.getTargets');
|
||||
let target = targets.targetInfos?.find((item) => item.type === 'page');
|
||||
if (!target) {
|
||||
const created = await this.cdp.send('Target.createTarget', { url: 'about:blank' });
|
||||
target = { targetId: created.targetId };
|
||||
}
|
||||
const attached = await this.cdp.send('Target.attachToTarget', {
|
||||
targetId: target.targetId,
|
||||
flatten: true,
|
||||
});
|
||||
await this.cdp.send('Page.enable', {}, attached.sessionId);
|
||||
await this.cdp.send('Runtime.enable', {}, attached.sessionId);
|
||||
return attached.sessionId;
|
||||
}
|
||||
|
||||
async inspect({ artifactPath, width, height, theme, screenshotPath }) {
|
||||
const sessionId = await this.sessionPromise;
|
||||
await this.cdp.send('Emulation.setDeviceMetricsOverride', {
|
||||
width,
|
||||
height,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
}, sessionId);
|
||||
|
||||
const url = new URL(pathToFileURL(artifactPath).href);
|
||||
url.searchParams.set('theme', theme);
|
||||
const loaded = this.cdp.waitFor('Page.loadEventFired', sessionId);
|
||||
const navigation = await this.cdp.send('Page.navigate', { url: url.href }, sessionId);
|
||||
if (navigation.errorText) throw new Error(`Chrome navigation failed: ${navigation.errorText}`);
|
||||
await loaded;
|
||||
await evaluate(this.cdp, sessionId, `(function () {
|
||||
document.documentElement.setAttribute('data-motion', 'still');
|
||||
var panel = document.querySelector('.diagram-container');
|
||||
if (panel) panel.setAttribute('data-detail-level', 'read');
|
||||
var fontsReady = document.fonts && document.fonts.ready
|
||||
? document.fonts.ready.catch(function () {})
|
||||
: Promise.resolve();
|
||||
return fontsReady.then(function () {
|
||||
if (window.Archify && Archify.readerLayout && typeof Archify.readerLayout.whenStable === 'function') {
|
||||
return Archify.readerLayout.whenStable();
|
||||
}
|
||||
}).then(function () {
|
||||
if (window.Archify && Archify.viewerChromeLayout && typeof Archify.viewerChromeLayout.whenStable === 'function') {
|
||||
return Archify.viewerChromeLayout.whenStable();
|
||||
}
|
||||
}).then(function () {
|
||||
if (window.Archify && Archify.readerLayout && typeof Archify.readerLayout.whenStable === 'function') {
|
||||
return Archify.readerLayout.whenStable();
|
||||
}
|
||||
}).then(function () {
|
||||
if (window.Archify && Archify.viewerChromeLayout && typeof Archify.viewerChromeLayout.whenStable === 'function') {
|
||||
return Archify.viewerChromeLayout.whenStable();
|
||||
}
|
||||
return new Promise(function (resolve) {
|
||||
requestAnimationFrame(function () { requestAnimationFrame(resolve); });
|
||||
});
|
||||
});
|
||||
})()`, true);
|
||||
|
||||
const metrics = await evaluate(this.cdp, sessionId, `(function () {
|
||||
var reader = document.querySelector('.container');
|
||||
var diagram = document.querySelector('.diagram-container');
|
||||
var svg = diagram && (
|
||||
diagram.querySelector(':scope > svg') ||
|
||||
diagram.querySelector(':scope > .diagram-stage > svg')
|
||||
);
|
||||
var stage = diagram && (diagram.querySelector(':scope > .diagram-stage') || svg);
|
||||
var legend = svg && svg.querySelector('[data-legend]');
|
||||
var navigationDock = diagram && diagram.querySelector('.diagram-nav');
|
||||
var viewBox = svg && svg.viewBox && svg.viewBox.baseVal;
|
||||
var diagramWidth = svg ? svg.getBoundingClientRect().width : 0;
|
||||
var viewBoxWidth = viewBox ? viewBox.width : 0;
|
||||
var scale = viewBoxWidth > 0 ? Math.min(1, diagramWidth / viewBoxWidth) : 0;
|
||||
var minimum = null;
|
||||
if (svg && scale > 0) {
|
||||
Array.from(svg.querySelectorAll('text[data-node-label], text[data-boundary-label], text[data-detail="context"]')).forEach(function (text) {
|
||||
var detail = text.hasAttribute('data-node-label')
|
||||
? 'primary'
|
||||
: text.hasAttribute('data-boundary-label') ? 'boundary' : 'context';
|
||||
if (detail === 'context' && !text.closest('[data-node-id]')) return;
|
||||
var sourceFontPx = parseFloat(text.getAttribute('font-size') || '');
|
||||
if (!Number.isFinite(sourceFontPx)) return;
|
||||
var projectedFontPx = sourceFontPx * scale;
|
||||
if (!minimum || projectedFontPx < minimum.projectedFontPx) {
|
||||
minimum = {
|
||||
text: (text.textContent || '').trim(),
|
||||
detail: detail,
|
||||
sourceFontPx: sourceFontPx,
|
||||
projectedFontPx: projectedFontPx
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
function intersectionArea(a, b) {
|
||||
if (!a || !b || !a.width || !a.height || !b.width || !b.height) return 0;
|
||||
var width = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left));
|
||||
var height = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
|
||||
return width * height;
|
||||
}
|
||||
var legendRect = legend ? legend.getBoundingClientRect() : null;
|
||||
var stageRect = window.Archify && Archify.viewerChromeLayout
|
||||
&& typeof Archify.viewerChromeLayout.stageRect === 'function'
|
||||
? Archify.viewerChromeLayout.stageRect()
|
||||
: (stage ? stage.getBoundingClientRect() : null);
|
||||
var navigationDockRect = navigationDock ? navigationDock.getBoundingClientRect() : null;
|
||||
var stageDockIntersectionArea = intersectionArea(stageRect, navigationDockRect);
|
||||
var viewerChromeReceipt = window.Archify && Archify.viewerChromeLayout
|
||||
&& typeof Archify.viewerChromeLayout.receipt === 'function'
|
||||
? Archify.viewerChromeLayout.receipt()
|
||||
: null;
|
||||
return {
|
||||
innerWidth: window.innerWidth,
|
||||
innerHeight: window.innerHeight,
|
||||
scrollWidth: Math.ceil(document.documentElement.scrollWidth),
|
||||
scrollHeight: Math.ceil(document.documentElement.scrollHeight),
|
||||
resolvedTheme: document.documentElement.getAttribute('data-theme') || '',
|
||||
readerWidth: reader ? reader.getBoundingClientRect().width : 0,
|
||||
diagramWidth: diagramWidth,
|
||||
viewBoxWidth: viewBoxWidth,
|
||||
minimumProjectedNodeTextPx: minimum ? minimum.projectedFontPx : null,
|
||||
minimumProjectedNodeText: minimum ? minimum.text : null,
|
||||
minimumProjectedNodeTextDetail: minimum ? minimum.detail : null,
|
||||
hasLegend: Boolean(legendRect && legendRect.width && legendRect.height),
|
||||
hasNavigationDock: Boolean(navigationDockRect && navigationDockRect.width && navigationDockRect.height),
|
||||
legendDockIntersectionArea: stageDockIntersectionArea > 0
|
||||
? intersectionArea(legendRect, navigationDockRect)
|
||||
: 0,
|
||||
dockStageIntersectionArea: stageDockIntersectionArea,
|
||||
dockStageGap: stageRect && navigationDockRect ? navigationDockRect.top - stageRect.bottom : null,
|
||||
viewerChromeRequiredGap: viewerChromeReceipt ? viewerChromeReceipt.gap : null,
|
||||
viewerChromeReserve: viewerChromeReceipt ? viewerChromeReceipt.reserve : 0,
|
||||
viewerChromeActive: viewerChromeReceipt ? viewerChromeReceipt.active : false
|
||||
};
|
||||
})()`);
|
||||
if (!metrics || !Number.isFinite(metrics.scrollWidth) || !Number.isFinite(metrics.scrollHeight)) {
|
||||
throw new Error('Chrome returned incomplete containment metrics.');
|
||||
}
|
||||
|
||||
if (screenshotPath) {
|
||||
const capture = await this.cdp.send('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: false,
|
||||
}, sessionId, 20000);
|
||||
if (!capture.data) throw new Error('Chrome returned an empty screenshot.');
|
||||
fs.writeFileSync(screenshotPath, Buffer.from(capture.data, 'base64'));
|
||||
}
|
||||
return metrics;
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.cdp.failAll(new Error('visual-check finished'));
|
||||
if (this.child.exitCode === null && this.child.signalCode === null) {
|
||||
this.child.kill('SIGTERM');
|
||||
await new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (this.child.exitCode === null && this.child.signalCode === null) this.child.kill('SIGKILL');
|
||||
resolve();
|
||||
}, 1500);
|
||||
this.child.once('exit', () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
try {
|
||||
fs.rmSync(this.profileRoot, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Chrome may briefly retain profile files on Windows; evidence is done.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function observation({ width, height, theme, metrics }) {
|
||||
const innerWidth = Number(metrics.innerWidth);
|
||||
const innerHeight = Number(metrics.innerHeight);
|
||||
const scrollWidth = Number(metrics.scrollWidth);
|
||||
const scrollHeight = Number(metrics.scrollHeight);
|
||||
const overflowX = scrollWidth > innerWidth;
|
||||
const overflowY = scrollHeight > innerHeight;
|
||||
const minimumProjectedNodeTextPx = metrics.minimumProjectedNodeTextPx == null
|
||||
? null
|
||||
: Number(metrics.minimumProjectedNodeTextPx);
|
||||
const readabilityOk = minimumProjectedNodeTextPx == null
|
||||
|| minimumProjectedNodeTextPx >= MIN_PROJECTED_NODE_TEXT_PX;
|
||||
const legendDockIntersectionArea = Number(metrics.legendDockIntersectionArea) || 0;
|
||||
const dockStageIntersectionArea = Number(metrics.dockStageIntersectionArea) || 0;
|
||||
const dockStageGap = metrics.dockStageGap == null ? null : Number(metrics.dockStageGap);
|
||||
const receiptDockStageGap = metrics.viewerChromeRequiredGap == null
|
||||
? null
|
||||
: Number(metrics.viewerChromeRequiredGap);
|
||||
const requiredDockStageGap = Number.isFinite(receiptDockStageGap) ? receiptDockStageGap : 0;
|
||||
const viewerChromeStageOk = !metrics.hasNavigationDock || (
|
||||
Number.isFinite(dockStageGap)
|
||||
&& dockStageIntersectionArea <= 0.5
|
||||
&& dockStageGap >= requiredDockStageGap - 1
|
||||
);
|
||||
const viewerChromeOk = legendDockIntersectionArea <= 0.5 && viewerChromeStageOk;
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
theme,
|
||||
innerWidth,
|
||||
innerHeight,
|
||||
scrollWidth,
|
||||
scrollHeight,
|
||||
overflowX,
|
||||
overflowY,
|
||||
ok: !overflowX && !overflowY,
|
||||
readerWidth: Number(metrics.readerWidth) || null,
|
||||
diagramWidth: Number(metrics.diagramWidth) || null,
|
||||
viewBoxWidth: Number(metrics.viewBoxWidth) || null,
|
||||
minimumProjectedNodeTextPx,
|
||||
minimumProjectedNodeText: metrics.minimumProjectedNodeText || null,
|
||||
minimumProjectedNodeTextDetail: metrics.minimumProjectedNodeTextDetail || null,
|
||||
minimumRequiredNodeTextPx: MIN_PROJECTED_NODE_TEXT_PX,
|
||||
readabilityOk,
|
||||
hasLegend: Boolean(metrics.hasLegend),
|
||||
hasNavigationDock: Boolean(metrics.hasNavigationDock),
|
||||
legendDockIntersectionArea,
|
||||
dockStageIntersectionArea,
|
||||
dockStageGap,
|
||||
requiredDockStageGap,
|
||||
viewerChromeStageOk,
|
||||
viewerChromeReserve: Number(metrics.viewerChromeReserve) || 0,
|
||||
viewerChromeActive: Boolean(metrics.viewerChromeActive),
|
||||
viewerChromeOk,
|
||||
resolvedTheme: metrics.resolvedTheme || theme,
|
||||
};
|
||||
}
|
||||
|
||||
function contactSheetHtml({ artifactPath, receipt, screenshots }) {
|
||||
const cards = screenshots.map((entry) => `
|
||||
<figure>
|
||||
<img src="${htmlEscape(entry.file)}" alt="${htmlEscape(`${entry.theme} ${entry.width} by ${entry.height}`)}">
|
||||
<figcaption><strong>${htmlEscape(entry.theme.toUpperCase())}</strong> · ${entry.width}×${entry.height} · containment ${entry.ok ? 'pass' : 'fail'}</figcaption>
|
||||
</figure>`).join('');
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Archify visual-check · ${htmlEscape(path.basename(artifactPath))}</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}body{margin:0;padding:24px;background:#e9eef5;color:#172033;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}header{max-width:1500px;margin:0 auto 18px}h1{margin:0 0 6px;font-size:20px}p{margin:0;color:#526176}.grid{max-width:1500px;margin:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}figure{margin:0;padding:10px;background:white;border:1px solid #c9d4e3;border-radius:12px;box-shadow:0 10px 30px rgba(15,23,42,.08)}img{display:block;width:100%;height:auto;border:1px solid #e2e8f0}figcaption{padding:9px 4px 2px;color:#526176}@media(max-width:900px){.grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>Archify visual-check</h1><p>${htmlEscape(path.basename(artifactPath))} · automated containment ${htmlEscape(receipt.containment.status)} · visual review pending</p></header>
|
||||
<main class="grid">${cards}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
function viewportSubject(artifact, entry) {
|
||||
return {
|
||||
artifact,
|
||||
viewport: { width: entry.width, height: entry.height, theme: entry.theme },
|
||||
};
|
||||
}
|
||||
|
||||
function failureDiagnostic({ code, message, subject, evidence, supportedFixes, severity = 'error' }) {
|
||||
return { code, severity, message, subject, evidence, supportedFixes };
|
||||
}
|
||||
|
||||
function observationDiagnostics({ artifact, allObservations, readabilityObservations }) {
|
||||
const diagnostics = [];
|
||||
for (const entry of allObservations) {
|
||||
if (!entry.ok) {
|
||||
diagnostics.push(failureDiagnostic({
|
||||
code: 'viewer/viewport-overflow',
|
||||
message: `The rendered artifact overflows the ${entry.width}x${entry.height} ${entry.theme} viewport.`,
|
||||
subject: viewportSubject(artifact, entry),
|
||||
evidence: {
|
||||
innerWidth: entry.innerWidth,
|
||||
innerHeight: entry.innerHeight,
|
||||
scrollWidth: entry.scrollWidth,
|
||||
scrollHeight: entry.scrollHeight,
|
||||
overflowX: entry.overflowX,
|
||||
overflowY: entry.overflowY,
|
||||
},
|
||||
supportedFixes: [
|
||||
`contain the rendered layout within ${entry.width}x${entry.height}, then rerun visual-check`,
|
||||
],
|
||||
}));
|
||||
}
|
||||
if (entry.legendDockIntersectionArea > 0.5) {
|
||||
diagnostics.push(failureDiagnostic({
|
||||
code: 'viewer/chrome-legend-clearance',
|
||||
message: `The navigation Dock obscures the SVG Legend at ${entry.width}x${entry.height} (${entry.theme}).`,
|
||||
subject: viewportSubject(artifact, entry),
|
||||
evidence: { legendDockIntersectionArea: entry.legendDockIntersectionArea },
|
||||
supportedFixes: [
|
||||
'move the SVG Legend or Viewer Dock until legendDockIntersectionArea is 0, then rerun visual-check',
|
||||
],
|
||||
}));
|
||||
}
|
||||
if (!entry.viewerChromeStageOk) {
|
||||
const stageOverlapsDock = entry.dockStageIntersectionArea > 0.5;
|
||||
diagnostics.push(failureDiagnostic({
|
||||
code: 'viewer/chrome-stage-clearance',
|
||||
message: stageOverlapsDock
|
||||
? `Navigation Dock enters the protected SVG stage at ${entry.width}x${entry.height} (${entry.theme}).`
|
||||
: `Navigation Dock clearance from the protected SVG stage is below the required gap at ${entry.width}x${entry.height} (${entry.theme}).`,
|
||||
subject: viewportSubject(artifact, entry),
|
||||
evidence: {
|
||||
dockStageIntersectionArea: entry.dockStageIntersectionArea,
|
||||
dockStageGap: entry.dockStageGap,
|
||||
requiredDockStageGap: entry.requiredDockStageGap,
|
||||
},
|
||||
supportedFixes: [
|
||||
`adjust Viewer stage reservation or clipping until dockStageGap is at least ${entry.requiredDockStageGap} and dockStageIntersectionArea is 0, then rerun visual-check`,
|
||||
],
|
||||
}));
|
||||
}
|
||||
}
|
||||
for (const entry of readabilityObservations) {
|
||||
if (entry.readabilityOk) continue;
|
||||
diagnostics.push(failureDiagnostic({
|
||||
code: 'viewer/projected-text-readability',
|
||||
message: `Projected ${entry.minimumProjectedNodeTextDetail || 'node'} text is below the readability floor at ${entry.width}x${entry.height}.`,
|
||||
subject: viewportSubject(artifact, entry),
|
||||
evidence: {
|
||||
text: entry.minimumProjectedNodeText,
|
||||
detail: entry.minimumProjectedNodeTextDetail,
|
||||
minimumProjectedNodeTextPx: entry.minimumProjectedNodeTextPx,
|
||||
minimumRequiredNodeTextPx: entry.minimumRequiredNodeTextPx,
|
||||
},
|
||||
supportedFixes: [
|
||||
`increase projected node text to at least ${entry.minimumRequiredNodeTextPx}px at ${entry.width}x${entry.height}, then rerun visual-check`,
|
||||
],
|
||||
}));
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function baseReceipt({ artifactPath, artifact, outputs, chrome }) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
ok: false,
|
||||
command: 'visual-check',
|
||||
status: 'fail',
|
||||
visualReview: 'pending',
|
||||
artifact: {
|
||||
path: artifactPath,
|
||||
sha256: sha256(artifact),
|
||||
bytes: artifact.byteLength,
|
||||
},
|
||||
state: { detail: 'read', motion: 'still' },
|
||||
chrome,
|
||||
diagnostics: [],
|
||||
containment: { status: 'fail', viewports: [] },
|
||||
readability: { status: 'fail', minimumProjectedNodeTextPx: MIN_PROJECTED_NODE_TEXT_PX, viewports: [] },
|
||||
viewerChrome: { status: 'fail', viewports: [] },
|
||||
captures: { status: 'fail', screenshots: [], contactSheet: null },
|
||||
sidecars: {
|
||||
receipt: path.basename(outputs.receipt),
|
||||
contactSheet: path.basename(outputs.contactSheet),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function persistReceipt(outputs, receipt) {
|
||||
writeAtomic(outputs.receipt, `${JSON.stringify(receipt, null, 2)}\n`);
|
||||
}
|
||||
|
||||
export async function runVisualCheck({
|
||||
artifactPath,
|
||||
chromePath,
|
||||
resolveChrome = findChrome,
|
||||
browserFactory = async (resolvedChrome) => new ChromeVisualBrowser(resolvedChrome),
|
||||
} = {}) {
|
||||
if (!artifactPath) throw new Error('visual-check requires one delivered HTML artifact.');
|
||||
const artifact = path.resolve(artifactPath);
|
||||
if (!/\.html?$/i.test(artifact)) throw new Error('visual-check requires an .html artifact.');
|
||||
const artifactBytes = fs.readFileSync(artifact);
|
||||
const outputs = sidecarPaths(artifact);
|
||||
cleanupCaptureSidecars(outputs);
|
||||
safeUnlink(outputs.receipt);
|
||||
|
||||
const resolvedChrome = chromePath || resolveChrome();
|
||||
const receipt = baseReceipt({
|
||||
artifactPath: artifact,
|
||||
artifact: artifactBytes,
|
||||
outputs,
|
||||
chrome: resolvedChrome
|
||||
? { status: 'available', executable: resolvedChrome }
|
||||
: { status: 'unavailable', executable: null },
|
||||
});
|
||||
|
||||
if (!resolvedChrome) {
|
||||
receipt.status = 'skipped';
|
||||
receipt.containment.status = 'skipped';
|
||||
receipt.readability.status = 'skipped';
|
||||
receipt.viewerChrome.status = 'skipped';
|
||||
receipt.captures.status = 'skipped';
|
||||
receipt.error = 'Chrome or Chromium is unavailable. Set ARCHIFY_CHROME to its executable path.';
|
||||
receipt.diagnostics = [failureDiagnostic({
|
||||
code: 'viewer/chrome-unavailable',
|
||||
severity: 'warning',
|
||||
message: receipt.error,
|
||||
subject: { artifact },
|
||||
evidence: { executable: null },
|
||||
supportedFixes: ['set ARCHIFY_CHROME to a Chrome or Chromium executable and rerun visual-check'],
|
||||
})];
|
||||
persistReceipt(outputs, receipt);
|
||||
return { exitCode: EXIT.skipped, receipt };
|
||||
}
|
||||
|
||||
let browser;
|
||||
try {
|
||||
browser = await browserFactory(resolvedChrome);
|
||||
const observations = new Map();
|
||||
const screenshotsByKey = new Map(outputs.screenshots.map((entry) => [
|
||||
screenshotKey(entry.width, entry.height, entry.theme),
|
||||
entry,
|
||||
]));
|
||||
|
||||
for (const viewport of VISUAL_CHECK_VIEWPORTS) {
|
||||
const key = screenshotKey(viewport.width, viewport.height, 'light');
|
||||
const screenshot = screenshotsByKey.get(key);
|
||||
const metrics = await browser.inspect({
|
||||
artifactPath: artifact,
|
||||
...viewport,
|
||||
theme: 'light',
|
||||
...(screenshot ? { screenshotPath: screenshot.path } : {}),
|
||||
});
|
||||
observations.set(key, observation({ ...viewport, theme: 'light', metrics }));
|
||||
}
|
||||
for (const viewport of CAPTURE_VIEWPORTS) {
|
||||
const key = screenshotKey(viewport.width, viewport.height, 'dark');
|
||||
const screenshot = screenshotsByKey.get(key);
|
||||
const metrics = await browser.inspect({
|
||||
artifactPath: artifact,
|
||||
...viewport,
|
||||
theme: 'dark',
|
||||
screenshotPath: screenshot.path,
|
||||
});
|
||||
observations.set(key, observation({ ...viewport, theme: 'dark', metrics }));
|
||||
}
|
||||
|
||||
const afterBytes = fs.readFileSync(artifact);
|
||||
if (sha256(afterBytes) !== receipt.artifact.sha256 || afterBytes.byteLength !== receipt.artifact.bytes) {
|
||||
throw new Error('The delivered artifact changed while visual-check was running.');
|
||||
}
|
||||
|
||||
receipt.containment.viewports = VISUAL_CHECK_VIEWPORTS.map(({ width, height }) => (
|
||||
observations.get(screenshotKey(width, height, 'light'))
|
||||
));
|
||||
receipt.readability.viewports = receipt.containment.viewports.map((entry) => ({ ...entry }));
|
||||
receipt.viewerChrome.viewports = receipt.containment.viewports.map((entry) => ({ ...entry }));
|
||||
receipt.captures.screenshots = outputs.screenshots.map((entry) => ({
|
||||
...observations.get(screenshotKey(entry.width, entry.height, entry.theme)),
|
||||
file: path.basename(entry.path),
|
||||
}));
|
||||
const allObservations = [...observations.values()];
|
||||
const containmentPass = allObservations.every((entry) => entry.ok);
|
||||
const readabilityPass = receipt.readability.viewports.every((entry) => entry.readabilityOk);
|
||||
const viewerChromePass = allObservations.every((entry) => entry.viewerChromeOk);
|
||||
receipt.diagnostics = observationDiagnostics({
|
||||
artifact,
|
||||
allObservations,
|
||||
readabilityObservations: receipt.readability.viewports,
|
||||
});
|
||||
receipt.containment.status = containmentPass ? 'pass' : 'fail';
|
||||
receipt.readability.status = readabilityPass ? 'pass' : 'fail';
|
||||
receipt.viewerChrome.status = viewerChromePass ? 'pass' : 'fail';
|
||||
receipt.captures.status = 'pass';
|
||||
receipt.captures.contactSheet = path.basename(outputs.contactSheet);
|
||||
receipt.status = containmentPass && readabilityPass && viewerChromePass ? 'pass' : 'fail';
|
||||
receipt.ok = containmentPass && readabilityPass && viewerChromePass;
|
||||
writeAtomic(outputs.contactSheet, contactSheetHtml({
|
||||
artifactPath: artifact,
|
||||
receipt,
|
||||
screenshots: receipt.captures.screenshots,
|
||||
}));
|
||||
persistReceipt(outputs, receipt);
|
||||
return { exitCode: receipt.ok ? EXIT.pass : EXIT.fail, receipt };
|
||||
} catch (error) {
|
||||
cleanupCaptureSidecars(outputs);
|
||||
receipt.status = 'fail';
|
||||
receipt.ok = false;
|
||||
receipt.error = error.message;
|
||||
receipt.containment.status = 'fail';
|
||||
receipt.readability.status = 'fail';
|
||||
receipt.viewerChrome.status = 'fail';
|
||||
receipt.captures.status = 'fail';
|
||||
receipt.captures.screenshots = [];
|
||||
receipt.captures.contactSheet = null;
|
||||
receipt.diagnostics = [failureDiagnostic({
|
||||
code: 'viewer/visual-check-runtime',
|
||||
message: 'visual-check could not complete its Chrome inspection.',
|
||||
subject: { artifact },
|
||||
evidence: { reason: error.message },
|
||||
supportedFixes: ['resolve the reported Chrome inspection error, then rerun visual-check'],
|
||||
})];
|
||||
persistReceipt(outputs, receipt);
|
||||
return { exitCode: EXIT.fail, receipt };
|
||||
} finally {
|
||||
if (browser?.close) await browser.close();
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "lifecycle",
|
||||
"meta": {
|
||||
"title": "Agent Run Lifecycle",
|
||||
"output": "examples/lifecycle-agent-run.html",
|
||||
"viewBox": [980, 660],
|
||||
"animation": "trace",
|
||||
"quality_profile": "showcase",
|
||||
"views": [
|
||||
{ "id": "main-lifecycle", "label": "Main lifecycle", "focus": ["queued", "planning", "executing", "reviewing", "completed"], "note": "Follow the ordered phases from accepted request to completed response." },
|
||||
{ "id": "human-waits", "label": "Human and input waits", "focus": ["executing", "approval", "reviewing", "blocked"], "note": "See where the run pauses without becoming terminal." },
|
||||
{ "id": "recovery-and-exits", "label": "Recovery and terminal exits", "focus": ["executing", "failed", "blocked", "cancelled", "expired"], "note": "Separate retryable failure from cancellation and expiry." }
|
||||
]
|
||||
},
|
||||
"lanes": [
|
||||
{ "id": "main", "label": "Lifecycle phases" },
|
||||
{ "id": "waiting", "label": "Interruptions" },
|
||||
{ "id": "exceptions", "label": "Recovery loop" },
|
||||
{ "id": "terminal", "label": "Terminal exits" }
|
||||
],
|
||||
"states": [
|
||||
{ "id": "queued", "type": "start", "label": "Queued", "sublabel": "request accepted", "lane": "main", "col": 0, "step": "01", "tag": "entry" },
|
||||
{ "id": "planning", "type": "active", "label": "Planning", "sublabel": "build task graph", "lane": "main", "col": 1, "step": "02", "tag": "model" },
|
||||
{ "id": "executing", "type": "active", "label": "Executing", "sublabel": "tool calls", "lane": "main", "col": 2, "step": "03", "tag": "work" },
|
||||
{ "id": "reviewing", "type": "decision", "label": "Reviewing", "sublabel": "quality gate", "lane": "main", "col": 3, "step": "04", "tag": "check" },
|
||||
{ "id": "completed", "type": "success", "label": "Completed", "sublabel": "final response", "lane": "main", "col": 4, "step": "05", "tag": "done" },
|
||||
{ "id": "approval", "type": "waiting", "label": "Needs Approval", "sublabel": "human gate", "lane": "waiting", "col": 0, "tag": "pause" },
|
||||
{ "id": "blocked", "type": "waiting", "label": "Blocked", "sublabel": "missing input", "lane": "waiting", "col": 1, "tag": "wait" },
|
||||
{ "id": "failed", "type": "failure", "label": "Failed", "sublabel": "recoverable error", "lane": "exceptions", "col": 0, "yOffset": 78, "tag": "retryable" },
|
||||
{ "id": "cancelled", "type": "failure", "label": "Cancelled", "sublabel": "user stopped", "lane": "terminal", "col": 0, "tag": "terminal" },
|
||||
{ "id": "expired", "type": "failure", "label": "Expired", "sublabel": "timeout", "lane": "terminal", "col": 1, "tag": "terminal" }
|
||||
],
|
||||
"transitions": [
|
||||
{ "id": "approval-needed", "from": "executing", "to": "approval", "variant": "security", "fromSide": "bottom", "toSide": "top", "route": "straight" },
|
||||
{ "id": "review-blocked", "from": "reviewing", "to": "blocked", "variant": "default", "route": "drop" },
|
||||
{ "id": "execution-failed", "from": "executing", "to": "failed", "variant": "security", "fromSide": "left", "toSide": "left", "via": [[320, 157], [320, 385]] },
|
||||
{ "id": "failed-retry", "from": "failed", "to": "executing", "variant": "emphasis", "fromSide": "left", "toSide": "top", "via": [[20, 385], [20, 80], [402, 80]] },
|
||||
{ "id": "block-expired", "from": "blocked", "to": "expired", "variant": "security", "fromSide": "bottom", "toSide": "top", "route": "straight" },
|
||||
{ "id": "approval-cancelled", "from": "approval", "to": "cancelled", "variant": "security", "fromSide": "bottom", "toSide": "top", "via": [[480, 336], [480, 432], [402, 432]] }
|
||||
],
|
||||
"cards": [
|
||||
{
|
||||
"dot": "emerald",
|
||||
"title": "Main Path",
|
||||
"items": [
|
||||
"The run has five ordered phases from queue to completion",
|
||||
"The primary lifecycle is carried by one horizontal rail",
|
||||
"Completion is a phase, not a detached side box"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "amber",
|
||||
"title": "Human + Input Gates",
|
||||
"items": [
|
||||
"Approval pauses execution without ending the run",
|
||||
"Blocked waits for missing user input",
|
||||
"Wait states remain non-terminal until cancellation or expiry"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "rose",
|
||||
"title": "Terminal + Recovery",
|
||||
"items": [
|
||||
"Failed loops back while retry budget remains",
|
||||
"Cancelled and Expired are exits from the lifecycle",
|
||||
"Terminal exits do not point back into active execution"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"diagram_type": "workflow",
|
||||
"meta": {
|
||||
"title": "Agent Tool Call Workflow",
|
||||
"animation": "trace",
|
||||
"visual_preset": "signal-flow",
|
||||
"quality_profile": "showcase",
|
||||
"views": [
|
||||
{
|
||||
"id": "happy-path",
|
||||
"label": "Request to result",
|
||||
"focus": ["user", "chat", "planner", "router", "approval", "tool", "external", "final"],
|
||||
"note": "Follow the successful request from user intent to the final reply."
|
||||
},
|
||||
{
|
||||
"id": "safety-gate",
|
||||
"label": "Policy and recovery",
|
||||
"focus": ["router", "approval", "blocked", "retry"],
|
||||
"note": "See where risky work stops, waits for consent, or returns for revision."
|
||||
},
|
||||
{
|
||||
"id": "evidence-loop",
|
||||
"label": "Evidence and memory",
|
||||
"focus": ["external", "store", "trace"],
|
||||
"note": "Isolate the durable trace and context path behind the visible answer."
|
||||
}
|
||||
],
|
||||
"output": "examples/workflow-agent-tool-call-rendered.html"
|
||||
},
|
||||
"lanes": [
|
||||
{ "id": "ui", "label": "User Interface" },
|
||||
{ "id": "agent", "label": "Agent Runtime" },
|
||||
{ "id": "policy", "label": "Policy & Recovery", "variant": "exception" },
|
||||
{ "id": "tools", "label": "Tool Execution & Evidence" }
|
||||
],
|
||||
"phases": [
|
||||
{ "id": "intake", "label": "Intake", "fromCol": 0, "toCol": 1 },
|
||||
{ "id": "reasoning", "label": "Plan + route", "fromCol": 2, "toCol": 3, "variant": "emphasis" },
|
||||
{ "id": "execution", "label": "Execute + report", "fromCol": 4, "toCol": 5, "variant": "dashed" }
|
||||
],
|
||||
"groups": [
|
||||
{ "id": "agent_loop", "label": "Planning loop", "lane": "agent", "fromCol": 2, "toCol": 3, "variant": "emphasis" },
|
||||
{ "id": "exception_path", "label": "Human or policy stop", "lane": "policy", "fromCol": 3, "toCol": 5, "variant": "security" },
|
||||
{ "id": "evidence_path", "label": "Evidence path", "lane": "tools", "fromCol": 1, "toCol": 2, "variant": "dashed" },
|
||||
{ "id": "tool_work", "label": "Tool work", "lane": "tools", "fromCol": 4, "toCol": 5, "variant": "dashed" }
|
||||
],
|
||||
"mainPath": ["user", "chat", "planner", "router", "approval", "tool", "external", "final"],
|
||||
"nodes": [
|
||||
{ "id": "user", "lane": "ui", "col": 0, "type": "external", "label": "User", "sublabel": "asks for work", "width": 132 },
|
||||
{ "id": "chat", "lane": "ui", "col": 1, "type": "frontend", "label": "Chat Surface", "sublabel": "thread + files", "width": 132 },
|
||||
{ "id": "final", "lane": "ui", "col": 5, "type": "backend", "label": "Final Reply", "sublabel": "answer + changes", "width": 132 },
|
||||
{ "id": "planner", "lane": "agent", "col": 2, "type": "backend", "label": "Agent Planner", "sublabel": "plan next step", "tag": "context aware", "width": 132 },
|
||||
{ "id": "router", "lane": "agent", "col": 3, "type": "backend", "label": "Tool Router", "sublabel": "choose capability", "width": 132 },
|
||||
{ "id": "approval", "lane": "policy", "col": 3, "type": "security", "label": "Approval Gate", "sublabel": "scope + consent", "tag": "block risky ops", "width": 132 },
|
||||
{ "id": "blocked", "lane": "policy", "col": 4, "type": "security", "label": "Blocked", "sublabel": "wait or reject", "width": 132 },
|
||||
{ "id": "retry", "lane": "policy", "col": 5, "type": "messagebus", "label": "Retry Path", "sublabel": "revise request", "width": 132 },
|
||||
{ "id": "tool", "lane": "tools", "col": 4, "type": "messagebus", "label": "Tool Call", "sublabel": "shell / browser / MCP", "tag": "structured result", "width": 132 },
|
||||
{ "id": "external", "lane": "tools", "col": 5, "type": "cloud", "label": "External API", "sublabel": "network service", "width": 132 },
|
||||
{ "id": "store", "lane": "tools", "col": 1, "type": "database", "label": "Context Store", "sublabel": "repo + memory", "width": 132 },
|
||||
{ "id": "trace", "lane": "tools", "col": 2, "type": "database", "label": "Trace Log", "sublabel": "events + output", "width": 132 }
|
||||
],
|
||||
"edges": [
|
||||
{ "id": "request-chat", "from": "user", "to": "chat", "variant": "default" },
|
||||
{ "id": "plan-request", "from": "chat", "to": "planner", "label": "plan", "variant": "emphasis" },
|
||||
{ "id": "planner-route", "from": "planner", "to": "router", "variant": "default" },
|
||||
{ "id": "approval-check", "from": "router", "to": "approval", "label": "needs approval?", "variant": "security" },
|
||||
{ "id": "approved-tool", "from": "approval", "to": "tool", "variant": "emphasis" },
|
||||
{ "id": "approval-denied", "from": "approval", "to": "blocked", "label": "denied", "variant": "security", "role": "error" },
|
||||
{ "id": "retry-request", "from": "blocked", "to": "retry", "variant": "dashed", "role": "branch" },
|
||||
{ "id": "tool-external-call", "from": "tool", "to": "external", "variant": "default" },
|
||||
{ "id": "external-reply", "from": "external", "to": "final", "variant": "emphasis", "role": "return", "fromSide": "right", "toSide": "right", "route": "outside-right", "width": 1.2 },
|
||||
{ "id": "record-result", "from": "external", "to": "trace", "label": "record result", "variant": "dashed", "fromSide": "bottom", "toSide": "bottom", "route": "bottom-channel", "labelSegment": 1 },
|
||||
{ "id": "write-trace-memory", "from": "store", "to": "trace", "label": "trace + memory", "variant": "dashed" }
|
||||
],
|
||||
"cards": [
|
||||
{
|
||||
"dot": "cyan",
|
||||
"title": "Compiler Contract",
|
||||
"items": [
|
||||
"Lanes and columns determine node placement",
|
||||
"Labels reserve clearance; routes stay orthogonal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "rose",
|
||||
"title": "Runtime Semantics",
|
||||
"items": [
|
||||
"Approval gates risky work before tool execution",
|
||||
"Evidence returns through isolated trace and memory"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "sequence",
|
||||
"meta": {
|
||||
"title": "Async Job Roundtrip",
|
||||
"output": "examples/async-job-roundtrip.html",
|
||||
"viewBox": [820, 920],
|
||||
"animation": "trace",
|
||||
"visual_preset": "signal-flow",
|
||||
"quality_profile": "showcase",
|
||||
"views": [
|
||||
{ "id": "accept-and-enqueue", "label": "Accept without blocking", "focus": ["client", "api", "queue"], "note": "The API acknowledges quickly after durable enqueue." },
|
||||
{ "id": "work-and-retry", "label": "Background work and retry", "focus": ["queue", "worker", "provider"], "note": "Timeouts re-enter the queue instead of holding the original request open." },
|
||||
{ "id": "observe-final-state", "label": "Observe final consistency", "focus": ["worker", "store", "notify", "client", "api"], "note": "Webhook delivery is primary; polling remains a bounded fallback." }
|
||||
]
|
||||
},
|
||||
"participants": [
|
||||
{ "id": "client", "type": "external", "label": "Client", "sublabel": "mobile app" },
|
||||
{ "id": "api", "type": "backend", "label": "Jobs API", "sublabel": "request edge" },
|
||||
{ "id": "queue", "type": "messagebus", "label": "Queue", "sublabel": "durable work" },
|
||||
{ "id": "worker", "type": "backend", "label": "Worker", "sublabel": "background" },
|
||||
{ "id": "provider", "type": "cloud", "label": "Provider", "sublabel": "external API" },
|
||||
{ "id": "store", "type": "database", "label": "Job Store", "sublabel": "source of truth" },
|
||||
{ "id": "notify", "type": "messagebus", "label": "Notifier", "sublabel": "webhook" }
|
||||
],
|
||||
"segments": [
|
||||
{ "from": 150, "to": 288, "label": "Accept" },
|
||||
{ "from": 306, "to": 538, "label": "Background work" },
|
||||
{ "from": 554, "to": 800, "label": "Notify + reconcile" }
|
||||
],
|
||||
"messages": [
|
||||
{ "from": "client", "to": "api", "y": 180, "label": "POST /jobs", "variant": "emphasis" },
|
||||
{ "from": "api", "to": "queue", "y": 222, "label": "enqueue job", "variant": "emphasis" },
|
||||
{ "from": "api", "to": "client", "y": 264, "label": "202 + job id", "variant": "return" },
|
||||
{ "from": "queue", "to": "worker", "y": 326, "label": "deliver", "variant": "emphasis" },
|
||||
{ "from": "worker", "to": "provider", "y": 368, "label": "perform work", "variant": "default" },
|
||||
{ "from": "provider", "to": "worker", "y": 410, "label": "result / timeout", "variant": "return" },
|
||||
{ "from": "worker", "to": "queue", "y": 452, "label": "retry if timeout", "variant": "dashed" },
|
||||
{ "from": "worker", "to": "store", "y": 494, "label": "persist final state", "variant": "emphasis" },
|
||||
{ "from": "worker", "to": "notify", "y": 566, "label": "job.completed", "variant": "dashed" },
|
||||
{ "from": "notify", "to": "client", "y": 608, "label": "signed webhook", "variant": "dashed" },
|
||||
{ "from": "client", "to": "api", "y": 650, "label": "GET /jobs/:id", "variant": "default" },
|
||||
{ "from": "api", "to": "store", "y": 692, "label": "read status", "variant": "default" },
|
||||
{ "from": "store", "to": "api", "y": 734, "label": "completed", "variant": "return" },
|
||||
{ "from": "api", "to": "client", "y": 776, "label": "200 final result", "variant": "return" }
|
||||
],
|
||||
"activations": [
|
||||
{ "participant": "api", "from": 174, "to": 272, "type": "backend" },
|
||||
{ "participant": "queue", "from": 216, "to": 334, "type": "messagebus" },
|
||||
{ "participant": "worker", "from": 320, "to": 574, "type": "backend" },
|
||||
{ "participant": "provider", "from": 362, "to": 416, "type": "cloud" },
|
||||
{ "participant": "store", "from": 488, "to": 742, "type": "database" },
|
||||
{ "participant": "notify", "from": 560, "to": 616, "type": "messagebus" },
|
||||
{ "participant": "api", "from": 644, "to": 784, "type": "backend" }
|
||||
],
|
||||
"cards": [
|
||||
{ "dot": "cyan", "title": "Fast Acknowledgement", "items": ["The caller receives a durable job id before work begins", "Queue ownership is visible in the acceptance contract", "The original connection does not wait for provider latency"] },
|
||||
{ "dot": "orange", "title": "Bounded Recovery", "items": ["Timeouts re-enter the queue with a retry policy", "Final state is persisted before notification", "The job store remains the source of truth"] },
|
||||
{ "dot": "emerald", "title": "Two Observation Paths", "items": ["A signed webhook announces completion", "Status polling is a fallback, not a second workflow", "Both paths converge on the same final state"] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "architecture",
|
||||
"meta": {
|
||||
"title": "Brand-aware AI delivery",
|
||||
"quality_profile": "showcase",
|
||||
"viewBox": [1120, 640],
|
||||
"views": [
|
||||
{
|
||||
"id": "delivery-path",
|
||||
"label": "Delivery path",
|
||||
"focus": ["request", "claude", "github", "container", "edge", "customer"],
|
||||
"note": "Follow one delivery from the request through the model, repository, container, and edge."
|
||||
},
|
||||
{
|
||||
"id": "business-data",
|
||||
"label": "Business and data",
|
||||
"focus": ["container", "database", "billing"],
|
||||
"note": "Inspect durable state and billing without losing the main delivery path."
|
||||
}
|
||||
]
|
||||
},
|
||||
"components": [
|
||||
{ "id": "request", "type": "external", "label": "Product request", "sublabel": "Owner brief", "pos": [38, 260], "size": [138, 68] },
|
||||
{ "id": "claude", "type": "frontend", "label": "Claude", "sublabel": "Plan and author", "brand": "claude", "pos": [220, 260], "size": [138, 68] },
|
||||
{ "id": "github", "type": "messagebus", "label": "GitHub", "sublabel": "Review and merge", "brand": "github", "pos": [402, 260], "size": [138, 68] },
|
||||
{ "id": "container", "type": "backend", "label": "Docker service", "sublabel": "Build and run", "brand": "docker", "pos": [584, 260], "size": [138, 68] },
|
||||
{ "id": "edge", "type": "cloud", "label": "Cloudflare", "sublabel": "Global delivery", "brand": "cloudflare", "pos": [766, 260], "size": [138, 68] },
|
||||
{ "id": "customer", "type": "external", "label": "Customers", "sublabel": "Web and mobile", "pos": [948, 260], "size": [138, 68] },
|
||||
{ "id": "database", "type": "database", "label": "PostgreSQL", "sublabel": "Durable state", "brand": "postgresql", "pos": [584, 420], "size": [138, 68] },
|
||||
{ "id": "billing", "type": "external", "label": "Stripe", "sublabel": "Billing events", "brand": "stripe", "pos": [766, 420], "size": [138, 68] }
|
||||
],
|
||||
"connections": [
|
||||
{ "id": "brief-to-claude", "from": "request", "to": "claude", "label": "brief", "variant": "emphasis" },
|
||||
{ "id": "claude-to-github", "from": "claude", "to": "github", "label": "change set", "labelDy": -28 },
|
||||
{ "id": "github-to-container", "from": "github", "to": "container", "label": "approved build", "labelDy": -28 },
|
||||
{ "id": "container-to-edge", "from": "container", "to": "edge", "label": "deploy" },
|
||||
{ "id": "edge-to-customer", "from": "edge", "to": "customer", "label": "HTTPS", "variant": "emphasis" },
|
||||
{ "id": "container-to-database", "from": "container", "to": "database", "label": "SQL", "fromSide": "bottom", "toSide": "top", "labelAt": [625, 370] },
|
||||
{ "id": "container-to-billing", "from": "container", "to": "billing", "label": "create charge", "variant": "dashed", "fromSide": "bottom", "toSide": "top", "via": [[653, 374], [835, 374]] },
|
||||
{ "id": "billing-to-database", "from": "billing", "to": "database", "label": "webhook", "variant": "dashed" }
|
||||
],
|
||||
"cards": [
|
||||
{ "dot": "cyan", "title": "Identity at a glance", "items": ["Semantic color still explains technical role", "Brand badges identify the actual products"] },
|
||||
{ "dot": "amber", "title": "Portable by default", "items": ["Preset marks ship inside Archify", "Every visual export keeps the same badge"] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "sequence",
|
||||
"meta": {
|
||||
"title": "Cache Miss Request Sequence",
|
||||
"output": "examples/sequence-cache-miss-request.html",
|
||||
"viewBox": [820, 760],
|
||||
"animation": "trace",
|
||||
"quality_profile": "showcase",
|
||||
"views": [
|
||||
{ "id": "request-and-auth", "label": "Request and identity", "focus": ["user", "web", "api", "auth"], "note": "Follow the user request through the authentication check." },
|
||||
{ "id": "cache-fallback", "label": "Cache fallback", "focus": ["api", "redis", "db"], "note": "See the cache miss and the source-of-truth query it triggers." },
|
||||
{ "id": "return-and-trace", "label": "Return and trace", "focus": ["db", "api", "redis", "trace", "web", "user"], "note": "Separate response latency from the non-blocking observability write." }
|
||||
]
|
||||
},
|
||||
"participants": [
|
||||
{ "id": "user", "type": "external", "label": "User", "sublabel": "browser session" },
|
||||
{ "id": "web", "type": "frontend", "label": "Web App", "sublabel": "React UI" },
|
||||
{ "id": "api", "type": "backend", "label": "API", "sublabel": "request handler" },
|
||||
{ "id": "auth", "type": "security", "label": "Auth", "sublabel": "JWT verify" },
|
||||
{ "id": "redis", "type": "database", "label": "Redis", "sublabel": "cache" },
|
||||
{ "id": "db", "type": "database", "label": "Postgres", "sublabel": "source of truth" },
|
||||
{ "id": "trace", "type": "messagebus", "label": "Trace", "sublabel": "async event" }
|
||||
],
|
||||
"segments": [
|
||||
{ "from": 150, "to": 295, "label": "Request" },
|
||||
{ "from": 315, "to": 505, "label": "Fallback" },
|
||||
{ "from": 525, "to": 665, "label": "Response + trace" }
|
||||
],
|
||||
"messages": [
|
||||
{ "id": "open-page", "from": "user", "to": "web", "y": 185, "label": "open page", "variant": "default" },
|
||||
{ "id": "dashboard-request", "from": "web", "to": "api", "y": 228, "label": "GET /dashboard", "variant": "emphasis" },
|
||||
{ "id": "verify-jwt", "from": "api", "to": "auth", "y": 270, "label": "verify JWT", "variant": "security" },
|
||||
{ "id": "auth-claims", "from": "auth", "to": "api", "y": 305, "label": "claims ok", "variant": "return" },
|
||||
{ "id": "cache-read", "from": "api", "to": "redis", "y": 354, "label": "read cache", "variant": "default" },
|
||||
{ "id": "cache-miss", "from": "redis", "to": "api", "y": 391, "label": "miss", "variant": "return" },
|
||||
{ "id": "profile-query", "from": "api", "to": "db", "y": 443, "label": "query profile + metrics", "variant": "emphasis" },
|
||||
{ "id": "profile-rows", "from": "db", "to": "api", "y": 489, "label": "rows", "variant": "return" },
|
||||
{ "id": "cache-write", "from": "api", "to": "redis", "y": 536, "label": "set cache", "variant": "dashed" },
|
||||
{ "id": "trace-emit", "from": "api", "to": "trace", "y": 580, "label": "emit trace", "variant": "dashed" },
|
||||
{ "id": "dashboard-response", "from": "api", "to": "web", "y": 625, "label": "200 JSON", "variant": "return" },
|
||||
{ "id": "page-render", "from": "web", "to": "user", "y": 662, "label": "render", "variant": "return" }
|
||||
],
|
||||
"activations": [
|
||||
{ "participant": "web", "from": 220, "to": 668, "type": "frontend" },
|
||||
{ "participant": "api", "from": 228, "to": 632, "type": "backend" },
|
||||
{ "participant": "auth", "from": 265, "to": 310, "type": "security" },
|
||||
{ "participant": "redis", "from": 349, "to": 398, "type": "database" },
|
||||
{ "participant": "db", "from": 438, "to": 496, "type": "database" },
|
||||
{ "participant": "trace", "from": 575, "to": 630, "type": "messagebus" }
|
||||
],
|
||||
"cards": [
|
||||
{
|
||||
"dot": "emerald",
|
||||
"title": "Happy Path",
|
||||
"items": [
|
||||
"The main request is Web App -> API -> data source -> response",
|
||||
"Return messages are quieter than forward calls",
|
||||
"Activation bars make ownership duration visible"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "rose",
|
||||
"title": "Policy + Fallback",
|
||||
"items": [
|
||||
"JWT verification is colored as a security interaction",
|
||||
"Cache miss is visible without overpowering the main path",
|
||||
"Database access only appears after cache fallback"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "orange",
|
||||
"title": "Async Trace",
|
||||
"items": [
|
||||
"Trace emission is dashed and secondary",
|
||||
"It does not block the response path",
|
||||
"The diagram separates user-facing latency from observability"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "architecture",
|
||||
"meta": {
|
||||
"title": "Checkout Platform — Baseline",
|
||||
"visual_preset": "signal-flow"
|
||||
},
|
||||
"components": [
|
||||
{ "id": "buyers", "type": "external", "label": "Buyers", "sublabel": "Web + mobile", "pos": [40, 250], "size": [120, 60] },
|
||||
{ "id": "edge", "type": "cloud", "label": "Edge Gateway", "sublabel": "TLS + routing", "pos": [220, 250], "size": [130, 60] },
|
||||
{ "id": "checkout", "type": "backend", "label": "Checkout API", "sublabel": "v1 service", "pos": [430, 250], "size": [130, 60] },
|
||||
{ "id": "cache", "type": "database", "label": "Session Cache", "sublabel": "Redis", "pos": [430, 100], "size": [130, 60] },
|
||||
{ "id": "orders", "type": "database", "label": "Orders", "sublabel": "PostgreSQL", "pos": [640, 250], "size": [130, 60] },
|
||||
{ "id": "queue", "type": "messagebus", "label": "Order Events", "sublabel": "durable queue", "pos": [430, 400], "size": [130, 60] },
|
||||
{ "id": "worker", "type": "backend", "label": "Fulfilment", "sublabel": "async worker", "pos": [640, 400], "size": [130, 60] },
|
||||
{ "id": "payments", "type": "external", "label": "Payment Rail", "sublabel": "external", "pos": [850, 250], "size": [130, 60] }
|
||||
],
|
||||
"boundaries": [
|
||||
{ "kind": "region", "label": "Production region", "wraps": ["edge", "checkout", "cache", "orders", "queue", "worker"] },
|
||||
{ "kind": "security-group", "label": "Checkout trust zone", "wraps": ["checkout", "orders"] }
|
||||
],
|
||||
"connections": [
|
||||
{ "id": "buyer-request", "from": "buyers", "to": "edge", "label": "HTTPS", "variant": "emphasis" },
|
||||
{ "id": "edge-checkout", "from": "edge", "to": "checkout" },
|
||||
{ "id": "session-read", "from": "checkout", "to": "cache", "label": "session", "fromSide": "top", "toSide": "bottom", "labelDy": -66 },
|
||||
{ "id": "persist-order", "from": "checkout", "to": "orders", "label": "SQL" },
|
||||
{ "id": "publish-order", "from": "checkout", "to": "queue", "label": "accepted", "variant": "dashed", "fromSide": "bottom", "toSide": "top", "labelDy": 66 },
|
||||
{ "id": "consume-order", "from": "queue", "to": "worker" },
|
||||
{ "id": "authorize-payment", "from": "orders", "to": "payments", "label": "authorize", "variant": "security" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "architecture",
|
||||
"meta": {
|
||||
"title": "Checkout Platform — Fraud Gate",
|
||||
"visual_preset": "signal-flow"
|
||||
},
|
||||
"components": [
|
||||
{ "id": "buyers", "type": "external", "label": "Buyers", "sublabel": "Web + mobile", "pos": [40, 250], "size": [120, 60] },
|
||||
{ "id": "edge", "type": "cloud", "label": "Edge Gateway", "sublabel": "TLS + routing", "pos": [220, 250], "size": [130, 60] },
|
||||
{ "id": "checkout", "type": "backend", "label": "Checkout API", "sublabel": "v2 idempotent", "pos": [430, 250], "size": [130, 60] },
|
||||
{ "id": "fraud", "type": "security", "label": "Fraud Gate", "sublabel": "policy scoring", "pos": [640, 100], "size": [130, 60], "tag": "new owner" },
|
||||
{ "id": "orders", "type": "database", "label": "Orders", "sublabel": "PostgreSQL", "pos": [640, 250], "size": [130, 60] },
|
||||
{ "id": "queue", "type": "messagebus", "label": "Order Events", "sublabel": "durable queue", "pos": [430, 420], "size": [130, 60] },
|
||||
{ "id": "worker", "type": "backend", "label": "Fulfilment", "sublabel": "async worker", "pos": [640, 400], "size": [130, 60] },
|
||||
{ "id": "payments", "type": "external", "label": "Payment Rail", "sublabel": "external", "pos": [850, 250], "size": [130, 60] }
|
||||
],
|
||||
"boundaries": [
|
||||
{ "kind": "region", "label": "Production region", "wraps": ["edge", "checkout", "fraud", "orders", "queue", "worker"] },
|
||||
{ "kind": "security-group", "label": "Checkout trust zone", "wraps": ["checkout", "fraud", "orders"] }
|
||||
],
|
||||
"connections": [
|
||||
{ "id": "buyer-request", "from": "buyers", "to": "edge", "label": "HTTPS", "variant": "emphasis" },
|
||||
{ "id": "edge-checkout", "from": "edge", "to": "checkout" },
|
||||
{ "id": "fraud-check", "from": "checkout", "to": "fraud", "label": "screen", "variant": "security", "fromSide": "top", "toSide": "bottom", "labelDy": -66 },
|
||||
{ "id": "persist-order", "from": "checkout", "to": "orders", "label": "SQL tx" },
|
||||
{ "id": "publish-order", "from": "checkout", "to": "queue", "label": "accepted", "variant": "dashed", "fromSide": "bottom", "toSide": "top", "labelDy": 76 },
|
||||
{ "id": "consume-order", "from": "queue", "to": "worker" },
|
||||
{ "id": "authorize-payment", "from": "fraud", "to": "payments", "label": "authorize", "variant": "security", "fromSide": "right", "toSide": "top", "via": [[820, 130], [915, 130]] }
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "lifecycle",
|
||||
"meta": {
|
||||
"title": "Deployment Release Lifecycle",
|
||||
"output": "examples/deployment-release.html",
|
||||
"viewBox": [980, 680],
|
||||
"animation": "trace",
|
||||
"visual_preset": "signal-flow",
|
||||
"quality_profile": "showcase",
|
||||
"views": [
|
||||
{ "id": "promotion-rail", "label": "Promotion rail", "focus": ["queued", "building", "verifying", "ready", "live"], "note": "Follow the deployment object from accepted change to healthy production." },
|
||||
{ "id": "approval-gate", "label": "Approval gate", "focus": ["verifying", "approval", "cancelled", "ready"], "note": "Approval pauses promotion and can terminate the release cleanly." },
|
||||
{ "id": "rollback-outcomes", "label": "Rollback outcomes", "focus": ["ready", "rollback", "failed", "live", "paused", "rolled_back"], "note": "Separate pre-promotion failure from post-promotion health regression." }
|
||||
]
|
||||
},
|
||||
"lanes": [
|
||||
{ "id": "main", "label": "Release phases" },
|
||||
{ "id": "waiting", "label": "Approval + health wait" },
|
||||
{ "id": "recovery", "label": "Rollback controller" },
|
||||
{ "id": "terminal", "label": "Terminal exits" }
|
||||
],
|
||||
"states": [
|
||||
{ "id": "queued", "type": "start", "label": "Queued", "sublabel": "change accepted", "lane": "main", "col": 0, "step": "01", "tag": "pending" },
|
||||
{ "id": "building", "type": "active", "label": "Building", "sublabel": "immutable image", "lane": "main", "col": 1, "step": "02", "tag": "running" },
|
||||
{ "id": "verifying", "type": "decision", "label": "Verifying", "sublabel": "tests + policy", "lane": "main", "col": 2, "step": "03", "tag": "gate" },
|
||||
{ "id": "ready", "type": "waiting", "label": "Ready", "sublabel": "promotion pending", "lane": "main", "col": 3, "step": "04", "tag": "approved" },
|
||||
{ "id": "live", "type": "success", "label": "Live", "sublabel": "production healthy", "lane": "main", "col": 4, "step": "05", "tag": "success" },
|
||||
{ "id": "approval", "type": "waiting", "label": "Needs Approval", "sublabel": "release owner", "lane": "waiting", "col": 0, "tag": "pause" },
|
||||
{ "id": "rollback", "type": "active", "label": "Rolling Back", "sublabel": "last good image", "lane": "recovery", "col": 1, "tag": "automatic" },
|
||||
{ "id": "paused", "type": "waiting", "label": "Health Paused", "sublabel": "SLO regression", "lane": "waiting", "col": 2, "tag": "observe" },
|
||||
{ "id": "cancelled", "type": "failure", "label": "Cancelled", "sublabel": "approval denied", "lane": "terminal", "col": 0, "tag": "terminal" },
|
||||
{ "id": "failed", "type": "failure", "label": "Failed", "sublabel": "rollback failed", "lane": "terminal", "col": 1, "tag": "terminal" },
|
||||
{ "id": "rolled_back", "type": "success", "label": "Rolled Back", "sublabel": "service restored", "lane": "terminal", "col": 2, "tag": "terminal" }
|
||||
],
|
||||
"transitions": [
|
||||
{ "from": "verifying", "to": "approval", "variant": "security", "route": "straight", "fromSide": "bottom", "toSide": "top" },
|
||||
{ "from": "approval", "to": "cancelled", "variant": "security", "route": "straight", "fromSide": "bottom", "toSide": "top" },
|
||||
{ "from": "ready", "to": "rollback", "variant": "security", "route": "straight", "fromSide": "bottom", "toSide": "top" },
|
||||
{ "from": "rollback", "to": "failed", "variant": "security", "route": "straight", "fromSide": "bottom", "toSide": "top" },
|
||||
{ "from": "live", "to": "paused", "variant": "dashed", "route": "straight", "fromSide": "bottom", "toSide": "top" },
|
||||
{ "from": "paused", "to": "rolled_back", "variant": "emphasis", "route": "straight", "fromSide": "bottom", "toSide": "top" }
|
||||
],
|
||||
"cards": [
|
||||
{ "dot": "cyan", "title": "Promotion Rail", "items": ["The release object moves through five ordered phases", "Verification and approval remain distinct states", "Live means production health is currently proven"] },
|
||||
{ "dot": "amber", "title": "Wait States", "items": ["Human approval can pause without consuming a worker", "A health regression pauses further rollout", "Every wait exposes the event required to continue"] },
|
||||
{ "dot": "rose", "title": "Explicit Endings", "items": ["Denied approval ends as Cancelled", "Rollback controller failure ends as Failed", "Successful rollback is a terminal restored outcome"] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "dataflow",
|
||||
"meta": {
|
||||
"title": "Order Event-stream Topology",
|
||||
"output": "examples/event-stream.html",
|
||||
"viewBox": [1080, 780],
|
||||
"animation": "trace",
|
||||
"visual_preset": "signal-flow",
|
||||
"quality_profile": "showcase",
|
||||
"views": [
|
||||
{ "id": "order-transit", "label": "Order event transit", "focus": ["checkout", "orders", "validate", "state", "fulfillment"], "note": "Follow an order from producer through ordered processing to fulfillment." },
|
||||
{ "id": "payment-transit", "label": "Payment event transit", "focus": ["billing", "payments", "enrich", "state", "analytics"], "note": "Track payment facts into the shared materialized state and analytics." },
|
||||
{ "id": "failure-and-replay", "label": "Failure and replay", "focus": ["validate", "enrich", "dlq", "replay", "ops"], "note": "Isolate dead letters, operator review, and controlled replay ownership." }
|
||||
]
|
||||
},
|
||||
"stages": [
|
||||
{ "label": "Producers" },
|
||||
{ "label": "Transit" },
|
||||
{ "label": "Processors" },
|
||||
{ "label": "State + recovery" },
|
||||
{ "label": "Consumers" }
|
||||
],
|
||||
"nodes": [
|
||||
{ "id": "checkout", "type": "frontend", "label": "Checkout API", "sublabel": "order producer", "stage": 0, "row": 0, "tag": "team commerce" },
|
||||
{ "id": "billing", "type": "backend", "label": "Billing API", "sublabel": "payment producer", "stage": 0, "row": 2, "tag": "team money" },
|
||||
{ "id": "orders", "type": "messagebus", "label": "orders.v1", "sublabel": "12 partitions", "stage": 1, "row": 0, "tag": "key: order_id" },
|
||||
{ "id": "payments", "type": "messagebus", "label": "payments.v2", "sublabel": "8 partitions", "stage": 1, "row": 2, "tag": "key: order_id" },
|
||||
{ "id": "validate", "type": "backend", "label": "Order Validate", "sublabel": "group fulfillment", "stage": 2, "row": 0, "tag": "ordered" },
|
||||
{ "id": "enrich", "type": "backend", "label": "Payment Enrich", "sublabel": "group analytics", "stage": 2, "row": 2, "tag": "at-least-once" },
|
||||
{ "id": "state", "type": "database", "label": "Order State", "sublabel": "materialized view", "stage": 3, "row": 1, "tag": "idempotent" },
|
||||
{ "id": "dlq", "type": "messagebus", "label": "events.dlq", "sublabel": "poison events", "stage": 3, "row": 4, "tag": "7-day retention" },
|
||||
{ "id": "fulfillment", "type": "backend", "label": "Fulfillment", "sublabel": "shipping workflow", "stage": 4, "row": 0, "tag": "consumer" },
|
||||
{ "id": "analytics", "type": "database", "label": "Analytics", "sublabel": "streaming facts", "stage": 4, "row": 2, "tag": "consumer" },
|
||||
{ "id": "replay", "type": "security", "label": "Replay Tool", "sublabel": "approved batch", "stage": 4, "row": 4, "tag": "operator gate" },
|
||||
{ "id": "ops", "type": "external", "label": "On-call", "sublabel": "DLQ owner", "stage": 4, "row": 3, "yOffset": -18, "tag": "SRE" }
|
||||
],
|
||||
"flows": [
|
||||
{ "from": "checkout", "to": "orders", "label": "OrderPlaced", "classification": "schema v1", "variant": "emphasis", "route": "straight" },
|
||||
{ "from": "billing", "to": "payments", "label": "PaymentCaptured", "classification": "schema v2", "variant": "emphasis", "route": "straight" },
|
||||
{ "from": "orders", "to": "validate", "label": "ordered orders", "classification": "consumer group", "variant": "emphasis", "route": "straight" },
|
||||
{ "from": "payments", "to": "enrich", "label": "payment facts", "classification": "at-least-once", "variant": "emphasis", "route": "straight" },
|
||||
{ "from": "validate", "to": "state", "label": "valid order", "classification": "idempotent", "variant": "emphasis", "route": "vertical-channel" },
|
||||
{ "from": "enrich", "to": "state", "label": "enriched payment", "classification": "idempotent", "variant": "default", "route": "vertical-channel" },
|
||||
{ "from": "state", "to": "fulfillment", "label": "ready orders", "classification": "read model", "variant": "emphasis", "route": "vertical-channel" },
|
||||
{ "from": "state", "to": "analytics", "label": "order facts", "classification": "non-PII", "variant": "default", "route": "vertical-channel" },
|
||||
{ "from": "validate", "to": "dlq", "label": "invalid event", "classification": "dead letter", "variant": "security", "fromSide": "top", "toSide": "top", "via": [[530, 80], [20, 80], [20, 550], [745, 550]], "labelAt": [300, 550] },
|
||||
{ "from": "enrich", "to": "dlq", "label": "poison event", "classification": "dead letter", "variant": "security", "route": "bottom-channel", "labelDy": 30 },
|
||||
{ "from": "dlq", "to": "ops", "label": "failure sample", "classification": "restricted", "variant": "security", "route": "vertical-channel" },
|
||||
{ "from": "dlq", "to": "replay", "label": "approved replay", "classification": "audited batch", "variant": "dashed", "route": "straight", "labelDy": 30 }
|
||||
],
|
||||
"cards": [
|
||||
{ "dot": "amber", "title": "Transit Contract", "items": ["Every event and topic is named", "Partition keys preserve per-order ordering", "Consumer groups expose processing ownership"] },
|
||||
{ "dot": "emerald", "title": "State + Delivery", "items": ["Processors write an idempotent materialized view", "Fulfillment and analytics consume distinct assets", "At-least-once delivery never implies duplicate business effects"] },
|
||||
{ "dot": "rose", "title": "Failure Ownership", "items": ["Poison events land in a retained dead-letter topic", "On-call inspects samples before replay", "Replay is gated, batched, and auditable"] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "workflow",
|
||||
"meta": {
|
||||
"title": "Incident Response Runbook",
|
||||
"output": "examples/incident-response.html",
|
||||
"animation": "trace",
|
||||
"visual_preset": "signal-flow",
|
||||
"quality_profile": "showcase",
|
||||
"views": [
|
||||
{ "id": "detect-and-triage", "label": "Detect and establish command", "focus": ["alert", "page", "triage", "declare"], "note": "Follow the first minutes from signal to an owned incident." },
|
||||
{ "id": "mitigate-and-verify", "label": "Mitigate and prove recovery", "focus": ["triage", "contain", "recover", "verify", "close"], "note": "Keep mitigation separate from the evidence required to close." },
|
||||
{ "id": "escalate-and-communicate", "label": "Escalation and communication", "focus": ["declare", "escalate", "update", "rollback"], "note": "See who is paged, what stakeholders hear, and when rollback begins." }
|
||||
]
|
||||
},
|
||||
"lanes": [
|
||||
{ "id": "signals", "label": "Signals" },
|
||||
{ "id": "responders", "label": "Incident Command" },
|
||||
{ "id": "mitigation", "label": "Service Mitigation" },
|
||||
{ "id": "recovery", "label": "Recovery Evidence" },
|
||||
{ "id": "communication", "label": "Stakeholder Communication" },
|
||||
{ "id": "exceptions", "label": "Escalation + Rollback", "variant": "exception" }
|
||||
],
|
||||
"phases": [
|
||||
{ "id": "detect", "label": "Detect", "fromCol": 0, "toCol": 1 },
|
||||
{ "id": "respond", "label": "Triage + mitigate", "fromCol": 2, "toCol": 3, "variant": "emphasis" },
|
||||
{ "id": "recover", "label": "Verify + close", "fromCol": 4, "toCol": 5, "variant": "dashed" }
|
||||
],
|
||||
"groups": [
|
||||
{ "id": "command", "label": "Incident command", "lane": "responders", "fromCol": 1, "toCol": 3, "variant": "emphasis" },
|
||||
{ "id": "exception_actions", "label": "If impact persists", "lane": "exceptions", "fromCol": 3, "toCol": 5, "variant": "security" }
|
||||
],
|
||||
"mainPath": ["alert", "page", "triage", "contain", "recover", "verify", "close"],
|
||||
"nodes": [
|
||||
{ "id": "alert", "lane": "signals", "col": 0, "type": "messagebus", "label": "SLO Alert", "sublabel": "burn rate" },
|
||||
{ "id": "page", "lane": "responders", "col": 1, "width": 76, "type": "external", "label": "Page On-call", "sublabel": "acknowledge" },
|
||||
{ "id": "triage", "lane": "responders", "col": 2, "width": 64, "type": "backend", "label": "Triage", "sublabel": "scope impact" },
|
||||
{ "id": "declare", "lane": "responders", "col": 3, "type": "security", "label": "Declare", "sublabel": "assign commander", "tag": "SEV-1/2" },
|
||||
{ "id": "contain", "lane": "mitigation", "col": 3, "width": 72, "type": "backend", "label": "Contain", "sublabel": "stop growth" },
|
||||
{ "id": "recover", "lane": "mitigation", "col": 4, "width": 52, "type": "cloud", "label": "Recover", "sublabel": "restore" },
|
||||
{ "id": "verify", "lane": "recovery", "col": 5, "type": "database", "label": "Verify", "sublabel": "SLO + traces", "tag": "15 min stable" },
|
||||
{ "id": "close", "lane": "communication", "col": 5, "type": "external", "label": "Resolve", "sublabel": "final update" },
|
||||
{ "id": "update", "lane": "communication", "col": 3, "type": "frontend", "label": "Status Update", "sublabel": "impact + ETA" },
|
||||
{ "id": "escalate", "lane": "exceptions", "col": 3, "width": 72, "type": "security", "label": "Escalate", "sublabel": "specialist" },
|
||||
{ "id": "rollback", "lane": "exceptions", "col": 5, "type": "messagebus", "label": "Rollback", "sublabel": "last good" }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "alert", "to": "page", "label": "page", "variant": "emphasis", "route": "drop", "fromSide": "bottom", "toSide": "top" },
|
||||
{ "from": "page", "to": "triage", "route": "bottom-channel", "fromSide": "bottom", "toSide": "bottom" },
|
||||
{ "from": "triage", "to": "contain", "variant": "emphasis", "route": "drop", "fromSide": "bottom", "toSide": "top" },
|
||||
{ "from": "contain", "to": "recover", "route": "bottom-channel", "fromSide": "bottom", "toSide": "bottom" },
|
||||
{ "from": "recover", "to": "verify", "variant": "emphasis", "route": "drop", "fromSide": "bottom", "toSide": "top" },
|
||||
{ "from": "verify", "to": "close", "variant": "emphasis", "route": "drop", "fromSide": "bottom", "toSide": "top" },
|
||||
{ "from": "triage", "to": "declare", "variant": "security" },
|
||||
{ "from": "declare", "to": "update", "variant": "dashed", "fromSide": "top", "toSide": "left", "via": [[430, 16], [20, 16], [20, 615]] },
|
||||
{ "from": "update", "to": "escalate", "variant": "security", "route": "drop", "fromSide": "bottom", "toSide": "top" },
|
||||
{ "from": "verify", "to": "rollback", "variant": "security", "role": "error", "route": "outside-right", "fromSide": "right", "toSide": "right" }
|
||||
],
|
||||
"cards": [
|
||||
{ "dot": "rose", "title": "Ownership First", "items": ["A page is not an incident until someone owns command", "Severity and scope are explicit before mitigation spreads", "Escalation names the missing expertise"] },
|
||||
{ "dot": "emerald", "title": "Recovery Is Evidence", "items": ["Mitigation can reduce impact without proving recovery", "SLOs and traces must stay healthy for a fixed window", "The final update follows verification, not optimism"] },
|
||||
{ "dot": "cyan", "title": "Communication Contract", "items": ["Stakeholders receive impact, action, and next update time", "Rollback remains visible as a deliberate response", "Every branch has an owner and observable exit"] }
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "dataflow",
|
||||
"meta": {
|
||||
"title": "Product Analytics Data Flow",
|
||||
"output": "examples/dataflow-product-analytics.html",
|
||||
"viewBox": [1080, 760],
|
||||
"animation": "trace",
|
||||
"quality_profile": "showcase",
|
||||
"views": [
|
||||
{ "id": "collection-path", "label": "Collection path", "focus": ["web", "mobile", "edge", "stream"], "note": "Follow product events from clients into the ordered event stream." },
|
||||
{ "id": "consent-boundary", "label": "Consent and PII", "focus": ["edge", "consent", "pii"], "note": "Isolate the policy gate and restricted identity store." },
|
||||
{ "id": "analytics-consumers", "label": "Curated consumers", "focus": ["stream", "warehouse", "dashboard", "features", "model"], "note": "See curated facts, dashboards, and the derived feature path." }
|
||||
]
|
||||
},
|
||||
"stages": [
|
||||
{ "label": "Sources" },
|
||||
{ "label": "Ingest" },
|
||||
{ "label": "Process" },
|
||||
{ "label": "Store" },
|
||||
{ "label": "Consume" }
|
||||
],
|
||||
"nodes": [
|
||||
{ "id": "web", "type": "frontend", "label": "Web App", "sublabel": "browser SDK", "stage": 0, "row": 0, "tag": "events" },
|
||||
{ "id": "mobile", "type": "frontend", "label": "Mobile", "sublabel": "iOS / Android", "stage": 0, "row": 2, "tag": "events" },
|
||||
{ "id": "edge", "type": "cloud", "label": "Edge API", "sublabel": "collector", "stage": 1, "row": 1, "tag": "TLS" },
|
||||
{ "id": "consent", "type": "security", "label": "Consent Gate", "sublabel": "policy filter", "stage": 2, "row": 0, "tag": "PII guard" },
|
||||
{ "id": "stream", "type": "messagebus", "label": "Event Stream", "sublabel": "Kafka topic", "stage": 2, "row": 2, "tag": "ordered" },
|
||||
{ "id": "pii", "type": "security", "label": "PII Vault", "sublabel": "encrypted", "stage": 3, "row": 0, "tag": "restricted" },
|
||||
{ "id": "warehouse", "type": "database", "label": "Warehouse", "sublabel": "analytics tables", "stage": 3, "row": 2, "tag": "curated" },
|
||||
{ "id": "features", "type": "database", "label": "Feature Store", "sublabel": "daily batch", "stage": 3, "row": 4, "tag": "derived" },
|
||||
{ "id": "dashboard", "type": "backend", "label": "Dashboards", "sublabel": "product metrics", "stage": 4, "row": 1, "tag": "SQL" },
|
||||
{ "id": "model", "type": "backend", "label": "ML Model", "sublabel": "ranking job", "stage": 4, "row": 4, "tag": "features" }
|
||||
],
|
||||
"flows": [
|
||||
{ "id": "web-clickstream", "from": "web", "to": "edge", "label": "clickstream", "classification": "user events", "variant": "emphasis", "fromSide": "right", "toSide": "left", "via": [[205, 157], [205, 271]], "labelAt": [204, 190] },
|
||||
{ "id": "mobile-events", "from": "mobile", "to": "edge", "label": "app events", "classification": "device events", "variant": "default", "fromSide": "right", "toSide": "left", "via": [[222, 385], [222, 271]], "labelAt": [220, 342] },
|
||||
{ "id": "consent-enrichment", "from": "edge", "to": "consent", "label": "identity + consent", "classification": "PII touch", "variant": "security", "fromSide": "top", "toSide": "left", "via": [[315, 112], [450, 112], [450, 157]], "labelAt": [382, 100] },
|
||||
{ "id": "accepted-events", "from": "edge", "to": "stream", "label": "accepted events", "classification": "append-only", "variant": "emphasis", "fromSide": "right", "toSide": "left", "via": [[420, 271], [420, 385]], "labelAt": [438, 324] },
|
||||
{ "id": "identity-map", "from": "consent", "to": "pii", "label": "identity map", "classification": "encrypted PII", "variant": "security", "route": "straight", "labelAt": [638, 144] },
|
||||
{ "id": "normalized-facts", "from": "stream", "to": "warehouse", "label": "normalized facts", "classification": "non-PII", "variant": "emphasis", "route": "straight", "labelAt": [638, 372] },
|
||||
{ "id": "daily-aggregates", "from": "warehouse", "to": "features", "label": "daily aggregates", "classification": "batch", "variant": "dashed", "fromSide": "bottom", "toSide": "top", "route": "straight", "labelAt": [745, 496] },
|
||||
{ "id": "metrics-query", "from": "warehouse", "to": "dashboard", "label": "metrics SQL", "classification": "read-only", "variant": "default", "fromSide": "right", "toSide": "left", "via": [[852, 385], [852, 271]], "labelAt": [830, 326] },
|
||||
{ "id": "feature-vectors", "from": "features", "to": "model", "label": "feature vectors", "classification": "derived", "variant": "dashed", "route": "straight", "labelAt": [852, 598] },
|
||||
{ "id": "restricted-join", "from": "pii", "to": "dashboard", "label": "restricted join", "classification": "approved only", "variant": "security", "fromSide": "right", "toSide": "top", "via": [[878, 157], [878, 212], [960, 212]], "labelAt": [880, 198] }
|
||||
],
|
||||
"cards": [
|
||||
{
|
||||
"dot": "emerald",
|
||||
"title": "Primary Data Path",
|
||||
"items": [
|
||||
"Events move left to right through source, ingest, process, store, and consume stages",
|
||||
"The hot path stays visually clear even with secondary batch flows",
|
||||
"Labels name data assets instead of generic API verbs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "rose",
|
||||
"title": "Sensitive Boundary",
|
||||
"items": [
|
||||
"Consent and PII paths are styled as security flows",
|
||||
"PII lands in a restricted vault, separate from the analytics warehouse",
|
||||
"Restricted joins are visible without implying default access"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "orange",
|
||||
"title": "Derived Consumers",
|
||||
"items": [
|
||||
"Dashboards read curated facts from the warehouse",
|
||||
"Feature vectors are derived by batch from analytics tables",
|
||||
"Consumption paths stay distinct from collection and consent handling"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "architecture",
|
||||
"meta": {
|
||||
"title": "Production Deployment Ownership",
|
||||
"output": "examples/production-deployment.html",
|
||||
"visual_preset": "blueprint",
|
||||
"animation": "trace",
|
||||
"quality_profile": "showcase",
|
||||
"engineering_profile": "deployment-ownership",
|
||||
"views": [
|
||||
{
|
||||
"id": "request-boundary",
|
||||
"label": "Request crosses the edge",
|
||||
"focus": ["clients", "edge", "gateway", "api_a", "api_b"],
|
||||
"note": "Follow public traffic into the private application network."
|
||||
},
|
||||
{
|
||||
"id": "state-ownership",
|
||||
"label": "State and ownership",
|
||||
"focus": ["api_a", "api_b", "redis", "postgres", "replica"],
|
||||
"note": "Separate stateless platform workloads from data-team-owned state."
|
||||
},
|
||||
{
|
||||
"id": "async-operations",
|
||||
"label": "Async and operations",
|
||||
"focus": ["api_b", "events", "worker", "audit", "observability"],
|
||||
"note": "See the asynchronous work and the evidence it emits."
|
||||
}
|
||||
]
|
||||
},
|
||||
"components": [
|
||||
{ "id": "clients", "type": "external", "label": "Customers", "sublabel": "web + mobile", "pos": [38, 300], "size": [122, 60] },
|
||||
{ "id": "edge", "type": "cloud", "label": "Global Edge", "sublabel": "CDN + WAF", "pos": [230, 300], "size": [126, 60], "tag": "edge team" },
|
||||
{ "id": "gateway", "type": "security", "label": "API Gateway", "sublabel": "public :443", "pos": [430, 300], "size": [128, 60], "tag": "platform" },
|
||||
{ "id": "api_a", "type": "backend", "label": "API Pods / AZ-a", "sublabel": "private subnet", "pos": [630, 195], "size": [136, 62], "tag": "app team" },
|
||||
{ "id": "api_b", "type": "backend", "label": "API Pods / AZ-b", "sublabel": "private subnet", "pos": [630, 405], "size": [136, 62], "tag": "app team" },
|
||||
{ "id": "redis", "type": "database", "label": "Redis", "sublabel": "multi-AZ cache", "pos": [840, 195], "size": [126, 62], "tag": "platform" },
|
||||
{ "id": "postgres", "type": "database", "label": "PostgreSQL", "sublabel": "primary / encrypted", "pos": [840, 405], "size": [126, 62], "tag": "data team" },
|
||||
{ "id": "events", "type": "messagebus", "label": "Event Bus", "sublabel": "orders.v1", "pos": [1040, 300], "size": [126, 60], "tag": "platform" },
|
||||
{ "id": "worker", "type": "backend", "label": "Workers", "sublabel": "private workload", "pos": [1190, 300], "size": [126, 60], "tag": "app team" },
|
||||
{ "id": "replica", "type": "database", "label": "DR Replica", "sublabel": "eu-west-1", "pos": [1040, 578], "size": [126, 62], "tag": "data team" },
|
||||
{ "id": "audit", "type": "cloud", "label": "Audit Archive", "sublabel": "immutable objects", "pos": [1190, 450], "size": [126, 62], "tag": "security" },
|
||||
{ "id": "observability", "type": "external", "label": "Observability", "sublabel": "metrics + traces", "pos": [1190, 85], "size": [126, 62], "tag": "SRE" }
|
||||
],
|
||||
"boundaries": [
|
||||
{ "kind": "region", "label": "AWS us-east-1 / production", "wraps": ["edge", "gateway", "api_a", "api_b", "redis", "postgres", "events", "worker", "audit"], "pad": 20 },
|
||||
{ "kind": "security-group", "label": "private application network", "wraps": ["api_a", "api_b", "redis", "postgres", "events", "worker"], "pad": 14 },
|
||||
{ "kind": "region", "label": "AWS eu-west-1 / disaster recovery", "wraps": ["replica"] },
|
||||
{ "kind": "security-group", "label": "DR private subnet", "wraps": ["replica"], "pad": 14 }
|
||||
],
|
||||
"connections": [
|
||||
{ "from": "clients", "to": "edge", "label": "HTTPS", "variant": "emphasis" },
|
||||
{ "from": "edge", "to": "gateway", "label": "mTLS", "variant": "security" },
|
||||
{ "from": "gateway", "to": "api_a", "label": "VPC route", "variant": "emphasis", "route": "orthogonal-h", "labelAt": [594, 275] },
|
||||
{ "from": "gateway", "to": "api_b", "label": "VPC route", "variant": "emphasis", "route": "orthogonal-h", "labelAt": [594, 385] },
|
||||
{ "from": "api_a", "to": "redis", "label": "cache", "route": "straight" },
|
||||
{ "from": "api_b", "to": "postgres", "label": "SQL", "route": "straight" },
|
||||
{ "from": "api_a", "to": "events", "label": "publish", "variant": "dashed", "fromSide": "top", "toSide": "top", "via": [[698, 170], [1103, 170]] },
|
||||
{ "from": "api_b", "to": "events", "variant": "dashed", "fromSide": "top", "toSide": "bottom", "via": [[698, 380], [1103, 380]] },
|
||||
{ "from": "events", "to": "worker", "variant": "emphasis" },
|
||||
{ "from": "postgres", "to": "replica", "label": "cross-region WAL", "variant": "security", "route": "orthogonal-v", "labelAt": [1003, 529] },
|
||||
{ "from": "worker", "to": "audit", "label": "evidence", "variant": "dashed", "fromSide": "bottom", "toSide": "top", "labelDy": 58 },
|
||||
{ "from": "worker", "to": "observability", "label": "OTLP", "variant": "dashed", "route": "orthogonal-v" }
|
||||
],
|
||||
"cards": [
|
||||
{ "dot": "cyan", "title": "Runtime Ownership", "items": ["Platform owns the edge, gateway, cache, and event bus", "Application teams own API pods and workers", "Data owns primary and disaster-recovery state"] },
|
||||
{ "dot": "rose", "title": "Named Crossings", "items": ["Public HTTPS terminates at the managed edge", "mTLS crosses into the application network", "Cross-region WAL is explicit and encrypted"] },
|
||||
{ "dot": "emerald", "title": "Operational Evidence", "items": ["Workers emit traces to SRE-owned observability", "Audit evidence lands in immutable storage", "Unknown placement should remain marked, never invented"] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "workflow",
|
||||
"meta": {
|
||||
"title": "Release Delivery Workflow",
|
||||
"output": "examples/release-delivery.html",
|
||||
"animation": "trace",
|
||||
"quality_profile": "showcase",
|
||||
"views": [
|
||||
{ "id": "commit-to-checks", "label": "Commit to green build", "focus": ["commit", "pull_request", "build", "checks"], "note": "Follow the change through reproducible build and blocking quality gates." },
|
||||
{ "id": "approval-to-production", "label": "Approve and promote", "focus": ["checks", "approval", "deploy", "verify_prod", "announce"], "note": "See who authorizes production and how success is verified." },
|
||||
{ "id": "rollback-path", "label": "Failure and rollback", "focus": ["checks", "failed", "verify_prod", "rollback", "deploy"], "note": "Isolate the two places where delivery stops or reverses safely." }
|
||||
]
|
||||
},
|
||||
"lanes": [
|
||||
{ "id": "dev", "label": "Developer" },
|
||||
{ "id": "ci", "label": "Continuous Integration" },
|
||||
{ "id": "approval", "label": "Release Governance" },
|
||||
{ "id": "environment", "label": "Production Environment" },
|
||||
{ "id": "communication", "label": "Release Communication" },
|
||||
{ "id": "exceptions", "label": "Failure + Rollback", "variant": "exception" }
|
||||
],
|
||||
"phases": [
|
||||
{ "id": "change", "label": "Change", "fromCol": 0, "toCol": 1 },
|
||||
{ "id": "verify", "label": "Build + verify", "fromCol": 2, "toCol": 3, "variant": "emphasis" },
|
||||
{ "id": "promote", "label": "Promote + observe", "fromCol": 4, "toCol": 5, "variant": "dashed" }
|
||||
],
|
||||
"groups": [
|
||||
{ "id": "blocking_checks", "label": "Blocking checks", "lane": "ci", "fromCol": 2, "toCol": 3, "variant": "emphasis" },
|
||||
{ "id": "rollback_work", "label": "Recovery path", "lane": "exceptions", "fromCol": 3, "toCol": 5, "variant": "security" }
|
||||
],
|
||||
"mainPath": ["commit", "pull_request", "build", "checks", "approval", "deploy", "verify_prod", "announce"],
|
||||
"nodes": [
|
||||
{ "id": "commit", "lane": "dev", "col": 0, "type": "frontend", "label": "Commit", "sublabel": "signed change" },
|
||||
{ "id": "pull_request", "lane": "dev", "col": 1, "type": "frontend", "label": "Pull Request", "sublabel": "reviewed diff" },
|
||||
{ "id": "build", "lane": "ci", "col": 2, "type": "backend", "label": "Build", "sublabel": "locked inputs", "tag": "reproducible" },
|
||||
{ "id": "checks", "lane": "ci", "col": 3, "type": "security", "label": "Quality Gates", "sublabel": "test + scan", "tag": "blocking" },
|
||||
{ "id": "approval", "lane": "approval", "col": 4, "type": "security", "label": "Approve", "sublabel": "release owner", "tag": "human gate" },
|
||||
{ "id": "deploy", "lane": "environment", "col": 4, "type": "cloud", "label": "Deploy", "sublabel": "canary 10%", "tag": "production" },
|
||||
{ "id": "verify_prod", "lane": "environment", "col": 5, "type": "backend", "label": "Verify", "sublabel": "smoke + SLO" },
|
||||
{ "id": "announce", "lane": "communication", "col": 5, "type": "external", "label": "Announce", "sublabel": "status + notes" },
|
||||
{ "id": "failed", "lane": "exceptions", "col": 2, "type": "security", "label": "Stop Release", "sublabel": "gate failed" },
|
||||
{ "id": "rollback", "lane": "exceptions", "col": 4, "width": 64, "type": "messagebus", "label": "Rollback", "sublabel": "last good image", "tag": "owner: on-call" }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "commit", "to": "pull_request" },
|
||||
{ "from": "pull_request", "to": "build", "label": "merge", "variant": "emphasis", "route": "drop", "fromSide": "bottom", "toSide": "top" },
|
||||
{ "from": "build", "to": "checks" },
|
||||
{ "from": "checks", "to": "approval", "variant": "emphasis", "route": "drop", "fromSide": "bottom", "toSide": "top" },
|
||||
{ "from": "approval", "to": "deploy", "variant": "security", "route": "drop", "fromSide": "bottom", "toSide": "top" },
|
||||
{ "from": "deploy", "to": "verify_prod" },
|
||||
{ "from": "verify_prod", "to": "announce", "label": "healthy", "variant": "emphasis", "route": "drop", "fromSide": "bottom", "toSide": "top" },
|
||||
{ "from": "checks", "to": "failed", "label": "red", "variant": "security", "role": "error", "route": "drop", "fromSide": "bottom", "toSide": "top" },
|
||||
{ "from": "verify_prod", "to": "rollback", "variant": "security", "role": "error", "route": "outside-right", "fromSide": "right", "toSide": "right" },
|
||||
{ "from": "rollback", "to": "deploy", "label": "restore", "variant": "dashed", "role": "return", "route": "return-left", "fromSide": "left", "toSide": "left" }
|
||||
],
|
||||
"cards": [
|
||||
{ "dot": "emerald", "title": "One Happy Path", "items": ["Every change is reviewed before a reproducible build", "Blocking checks must be green before human approval", "Production is complete only after smoke and SLO verification"] },
|
||||
{ "dot": "rose", "title": "Stop Conditions", "items": ["Test or security failure stops promotion", "Production health can reverse a release", "Rollback ownership is visible before an incident"] },
|
||||
{ "dot": "cyan", "title": "Release Evidence", "items": ["Approval, immutable image, and check results are retained", "The release announcement follows verification", "The main path remains readable without hiding failure"] }
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "architecture",
|
||||
"meta": {
|
||||
"title": "Sample Web App",
|
||||
"output": "web-app-rendered.html",
|
||||
"quality_profile": "showcase",
|
||||
"views": [
|
||||
{ "id": "request-path", "label": "Primary request path", "focus": ["users", "cdn", "lb", "api", "db"], "note": "Follow the primary customer request from the edge to durable state." },
|
||||
{ "id": "identity-and-cache", "label": "Identity and cache", "focus": ["auth", "api", "cache"], "note": "Isolate authentication and the read-through cache beside the request path." },
|
||||
{ "id": "async-work", "label": "Static and async work", "focus": ["cdn", "s3", "api", "queue", "worker"], "note": "See the two secondary paths without adding noise to the main request." }
|
||||
]
|
||||
},
|
||||
"components": [
|
||||
{ "id": "users", "type": "external", "label": "Users", "sublabel": "Browser / Mobile", "pos": [40, 300], "size": [120, 60] },
|
||||
{ "id": "auth", "type": "security", "label": "Auth Provider", "sublabel": "OAuth 2.0", "pos": [40, 110], "size": [120, 64], "tag": "JWT + PKCE" },
|
||||
{ "id": "cdn", "type": "cloud", "label": "CloudFront", "sublabel": "CDN", "pos": [250, 300], "size": [130, 60] },
|
||||
{ "id": "lb", "type": "cloud", "label": "Load Balancer", "sublabel": "HTTPS :443", "pos": [460, 300], "size": [130, 60] },
|
||||
{ "id": "api", "type": "backend", "label": "API Server", "sublabel": "FastAPI :8000", "pos": [670, 300], "size": [130, 60] },
|
||||
{ "id": "cache", "type": "database", "label": "Redis", "sublabel": "cache :6379", "pos": [670, 150], "size": [130, 60] },
|
||||
{ "id": "db", "type": "database", "label": "PostgreSQL", "sublabel": "primary :5432", "pos": [880, 300], "size": [130, 60] },
|
||||
{ "id": "s3", "type": "cloud", "label": "S3", "sublabel": "static assets", "pos": [250, 440], "size": [130, 60], "tag": "OAI protected" },
|
||||
{ "id": "queue", "type": "messagebus", "label": "SQS", "sublabel": "job queue", "pos": [670, 440], "size": [130, 60] },
|
||||
{ "id": "worker", "type": "backend", "label": "Worker", "sublabel": "async jobs", "pos": [880, 440], "size": [130, 60] }
|
||||
],
|
||||
"boundaries": [
|
||||
{ "kind": "region", "label": "AWS Region: us-west-2", "wraps": ["cdn", "lb", "api", "cache", "db", "s3", "queue", "worker"] },
|
||||
{ "kind": "security-group", "label": "sg-api :443/:8000", "wraps": ["lb", "api"] }
|
||||
],
|
||||
"connections": [
|
||||
{ "id": "users-to-cdn", "from": "users", "to": "cdn", "label": "HTTPS", "variant": "emphasis" },
|
||||
{ "id": "jwt-verification", "from": "auth", "to": "api", "label": "verify JWT", "variant": "security", "fromSide": "right", "toSide": "top", "via": [[620, 142], [620, 246], [735, 246]] },
|
||||
{ "id": "cdn-to-lb", "from": "cdn", "to": "lb" },
|
||||
{ "id": "static-assets", "from": "cdn", "to": "s3", "label": "static", "variant": "dashed", "fromSide": "bottom", "toSide": "top", "labelDy": 58 },
|
||||
{ "id": "lb-to-api", "from": "lb", "to": "api" },
|
||||
{ "id": "cache-read-through", "from": "api", "to": "cache", "label": "read-through", "fromSide": "top", "toSide": "bottom", "labelDy": -68 },
|
||||
{ "id": "api-sql", "from": "api", "to": "db", "label": "SQL" },
|
||||
{ "id": "enqueue-job", "from": "api", "to": "queue", "label": "enqueue", "variant": "dashed", "fromSide": "bottom", "toSide": "top", "labelDy": 58 },
|
||||
{ "id": "queue-to-worker", "from": "queue", "to": "worker" }
|
||||
],
|
||||
"cards": [
|
||||
{ "dot": "cyan", "title": "Edge", "items": ["CloudFront CDN fronts all traffic", "S3 serves static assets via OAI"] },
|
||||
{ "dot": "emerald", "title": "Application", "items": ["FastAPI behind an HTTPS load balancer", "Redis read-through cache", "Async work drained from SQS by a worker"] },
|
||||
{ "dot": "rose", "title": "Security", "items": ["OAuth 2.0 with JWT + PKCE", "API + LB isolated in a security group"] }
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,279 @@
|
||||
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,
|
||||
...(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`;
|
||||
}
|
||||
Generated
+149
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"name": "archify",
|
||||
"version": "2.16.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "archify",
|
||||
"version": "2.16.0",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"archify": "bin/archify.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"parse5": "7.3.0",
|
||||
"saxes": "6.0.0",
|
||||
"simple-icons": "16.28.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv/node_modules/fast-uri": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-icons": {
|
||||
"version": "16.28.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.28.0.tgz",
|
||||
"integrity": "sha512-sQPR5AtK/ijRjou7zw7mlLp08oB6FH7i0lOy5XJ2zp9mJs/yejgiOn7KvQoe2q4YJIx6VmgUSW5AOefebPt5kg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/simple-icons"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/simple-icons"
|
||||
}
|
||||
],
|
||||
"license": "CC0-1.0",
|
||||
"engines": {
|
||||
"node": ">=0.12.18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "archify",
|
||||
"version": "2.16.0",
|
||||
"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: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": "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"
|
||||
},
|
||||
"overrides": {
|
||||
"fast-uri": "3.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
const RAW_RECIPES = [
|
||||
{
|
||||
id: 'system-overview', type: 'architecture', proof: 'web-app',
|
||||
presentation: { preset: 'classic', motion: 'static', views: 'optional' },
|
||||
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', views: 'recommended' },
|
||||
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', views: 'recommended' },
|
||||
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', views: 'optional' },
|
||||
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', views: 'recommended' },
|
||||
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', views: 'optional' },
|
||||
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', views: 'recommended' },
|
||||
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', views: 'recommended' },
|
||||
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', views: 'recommended' },
|
||||
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', views: 'optional' },
|
||||
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', views: 'recommended' },
|
||||
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 生命周期模式建模部署对象。展示排队、构建、验证、等待审批、晋级、回滚以及所有终态,并标注允许每次状态转换的事件和守卫条件。',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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} · views ${recipe.presentation.views}`,
|
||||
'',
|
||||
`${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,191 @@
|
||||
# Authoring contract
|
||||
|
||||
Read this reference only after the Fast authoring path calls for more detail. The schemas and examples remain authoritative.
|
||||
|
||||
## 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. Use the nearest matching example for structure, then 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).
|
||||
|
||||
## 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.
|
||||
For supported languages, always write the matching `meta.locale`: `"en"` for
|
||||
English or `"zh-CN"` for Simplified Chinese. 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,
|
||||
guided views, 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.
|
||||
|
||||
For a requested language outside `en` and `zh-CN`, do not write an unsupported
|
||||
locale. 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.
|
||||
|
||||
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
|
||||
|
||||
- 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.
|
||||
- A side is a direction contract. The first and final route segment must be perpendicular and outward/inward in the named direction.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
### Spacing and labels
|
||||
|
||||
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.
|
||||
|
||||
### 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, and route rhythm.
|
||||
5. Fix label-to-node, label-to-label, then label-to-route clearance.
|
||||
|
||||
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
|
||||
|
||||
Use one left-to-right spine with short vertical branches. Prefer 6–12 primary components and 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.
|
||||
|
||||
### 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
|
||||
|
||||
Main phases use columns `0..4`; event and terminal bands use columns `0..2`.
|
||||
Event/terminal column `N` aligns to the same x coordinate as main column
|
||||
`N + 2`. A recoverable failure needs a real transition back to an active state.
|
||||
A card or guided view saying “retry” is not topology.
|
||||
|
||||
## Repository evidence
|
||||
|
||||
When an architecture 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 architecture-only and is accepted by architecture
|
||||
`render`, `validate`, `deliver`, `preview`, and `compare`; workflow, sequence,
|
||||
dataflow, and lifecycle reject it. Never infer runtime causality from file
|
||||
proximity or naming alone.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,65 @@
|
||||
# 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Delivery contract
|
||||
|
||||
## Validate and deliver
|
||||
|
||||
Use `validate` after every candidate edit. Use final atomic 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`. Renderer, checker, receipt, or commit failure exits non-zero, removes private state, preserves the previous trusted artifact, and never invokes an opener.
|
||||
|
||||
Run `visual-check` only after `deliver` exits zero for the current candidate. If
|
||||
delivery fails and the output path already exists, that path still names the
|
||||
previous trusted artifact; running `visual-check` then would measure and capture
|
||||
stale output, not the rejected candidate. Report the delivery diagnostics and
|
||||
repair the source before collecting new visual evidence.
|
||||
|
||||
The deterministic receipt proves byte identity and automated checks. Never claim that the deterministic receipt includes visual review.
|
||||
|
||||
## Automated visual evidence
|
||||
|
||||
After delivery, inspect the exact trusted HTML without rerendering or modifying
|
||||
it:
|
||||
|
||||
```bash
|
||||
node bin/archify.mjs visual-check <output.html> --json
|
||||
```
|
||||
|
||||
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, then captures light/dark screenshots at 1440×900 and 2048×1320. It
|
||||
writes four PNG sidecars, one relative-path HTML contact sheet, and one JSON
|
||||
receipt beside the artifact. The receipt binds the source artifact SHA-256 and
|
||||
byte count, records READ plus Still runtime state, and always reports
|
||||
`visualReview: "pending"`; automated evidence cannot claim perceptual review.
|
||||
|
||||
Exit 0 means every containment measurement and capture passed. Exit 1 means an
|
||||
overflow or capture failure. Exit 2 means Chrome/Chromium was unavailable and
|
||||
the receipt status is `skipped`. Failed or skipped capture runs remove stale
|
||||
image/contact-sheet sidecars rather than presenting prior evidence as current.
|
||||
|
||||
## Optional opening
|
||||
|
||||
Add `--open` only when the user wants an immediate local preview. It runs after that atomic commit, uses one argument-array OS opener with a five-second bound, and records `open.status`. 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. Server state, port, source path, diagnostics, error text, and reload tokens must never enter the generated artifact or any export.
|
||||
|
||||
## Perceptual delivery gate
|
||||
|
||||
Automated validation cannot prove visual polish. After deterministic delivery, inspect the actual HTML in a capable browser or render a screenshot with an image reader. Check both themes when changed, the default READ view, line crossings/corridors, label masks, node/card fit, focus/search/passport closure, and export cleanliness.
|
||||
|
||||
For the default standalone desktop viewer, measure 1440×900, 1600×1000, and 1920×1080. When the artifact is intended for a large desktop display, also measure 2048×1320. A first-screen pass requires `document.documentElement.scrollWidth <= window.innerWidth` and `scrollHeight <= window.innerHeight` at every checked size. 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. If a desktop viewport overflows, 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.
|
||||
|
||||
Report exactly one truthful status:
|
||||
|
||||
- `visual_review: passed` — only after inspecting the rendered artifact.
|
||||
- `visual_review: skipped (image reader unavailable)` — when no capable visual surface exists.
|
||||
- `visual_review: failed` — with the concrete visible defect.
|
||||
|
||||
Use `correction_rounds: 0`, `correction_rounds: 1`, or `correction_rounds: 2`; never exceed a maximum of two focused correction rounds. Never report `visual_review: passed` without inspecting the artifact.
|
||||
|
||||
If visual review changes the candidate, validation and delivery must run again because the prior frozen specification receipt is 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
|
||||
visual_review: passed|skipped (image reader unavailable)|failed
|
||||
correction_rounds: 0|1|2
|
||||
```
|
||||
|
||||
Opening, preview status, Share Cards, and other viewer exports are not validation claims.
|
||||
@@ -0,0 +1,45 @@
|
||||
# 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, story, 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.
|
||||
|
||||
## Guided views and story
|
||||
|
||||
`meta.views` may define at most five curated chapters using stable node IDs. The Named Chapter Rail, Chapter Delta Preview, Story Beat Navigator, Story Follow Camera, Story Director Strip, Story Horizon, and Shareable Story Moment links all derive from that one authored array; none owns parallel topology or layout.
|
||||
|
||||
Story transitions classify only the exact relationship between adjacent authored stops: forward, reverse, multiple, or grouped/no direct link. Never infer a transitive edge, verb, causality, or runtime behavior from proximity, kinds, or story order. Playback is reader-started, bounded, stale-safe, and motion-governed.
|
||||
|
||||
## 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, story, camera, radar, presentation, motion ownership, and temporary overlays—must be removed from canonical export.
|
||||
|
||||
### Share Card
|
||||
|
||||
The optional 1200×630 Share Card PNG is for README, release, social, or launch previews. It uses the current theme and visual preset, contains the complete canonical diagram without cropping, and never claims validation. Copy Share Card reuses the same canonical PNG when clipboard image writes are supported.
|
||||
|
||||
### 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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
# Data Flow Renderer
|
||||
|
||||
Render `diagram_type: "dataflow"` JSON files into the standard Archify HTML
|
||||
template.
|
||||
|
||||
```bash
|
||||
node archify/renderers/dataflow/render-dataflow.mjs input.dataflow.json output.html
|
||||
```
|
||||
|
||||
The renderer validates input against `archify/schemas/dataflow.schema.json`
|
||||
with the bundled standalone validator. No dependency installation is required.
|
||||
|
||||
If `output.html` is omitted, the renderer uses `meta.output` from the JSON file
|
||||
or falls back to `dataflow.html` in the current working directory.
|
||||
|
||||
## Input
|
||||
|
||||
Data-flow JSON files must set:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "dataflow",
|
||||
"meta": {
|
||||
"title": "Product Analytics Data Flow",
|
||||
"viewBox": [940, 720]
|
||||
},
|
||||
"stages": [],
|
||||
"nodes": [],
|
||||
"flows": [],
|
||||
"cards": []
|
||||
}
|
||||
```
|
||||
|
||||
A complete worked example lives at
|
||||
`archify/examples/product-analytics.dataflow.json`.
|
||||
|
||||
The schema lives at:
|
||||
|
||||
```text
|
||||
archify/schemas/dataflow.schema.json
|
||||
```
|
||||
|
||||
## Legend
|
||||
|
||||
The default visual legend derives kinds from `flows[].variant` (omitting
|
||||
`variant` means `default`) and adds `database` only when a database node exists.
|
||||
Supported `meta.legend.entries` keys, in stable order, are `emphasis`,
|
||||
`security`, `dashed`, `database`, and `default`. Flow variants remain
|
||||
visual-only because Archify has no compiled edge-kind facts in this slice. A
|
||||
present `database` entry is different: it comes from exact
|
||||
`nodes[].type: "database"` facts, so it publishes the normal Semantic Legend
|
||||
count, accessible name, and keyboard interaction. Forcing `database` visible
|
||||
without a database node keeps it visual-only.
|
||||
|
||||
## Layout budget
|
||||
|
||||
| Constant | Value |
|
||||
|----------|-------|
|
||||
| viewBox | default `[940, 720]`; schema minimum `[360, 360]` |
|
||||
| Stages (2–5) | centers at x = 100 + stage×215; stage band 168 wide, header at y 46 |
|
||||
| Row tops (`row` 0–4) | y = 128, 242, 356, 470, 584 (plus `yOffset`) |
|
||||
| Default node | 112×58 |
|
||||
| Node area | x within `[24, width − 24]`; y within `[104, height − 74]` |
|
||||
| Node spacing | ≥10px between any two nodes (checked across stages and rows) |
|
||||
| Flow length | ≥34px between endpoints |
|
||||
| Legend row | y = height − 36 |
|
||||
|
||||
Route presets for flows: `straight`, `vertical-channel`, `bottom-channel`,
|
||||
`top-channel`, explicit `via` points, or the default `auto` (midpoint elbow).
|
||||
|
||||
## Design Rules
|
||||
|
||||
- Use stages for data lifecycle boundaries: source, ingest, process, store,
|
||||
consume.
|
||||
- Place nodes by stage index and row index; do not hand-place raw SVG for the
|
||||
common case.
|
||||
- Use flow labels to name the data asset, not the transport primitive:
|
||||
`clickstream`, `identity map`, `normalized facts`, `feature vectors`.
|
||||
- Use `classification` for short sensitivity or governance context:
|
||||
`PII touch`, `non-PII`, `approved only`, `batch`, `read-only`.
|
||||
- Use `security` for PII, policy, consent, access-control, or restricted joins.
|
||||
- Use `emphasis` for the primary data path and `dashed` for async or batch
|
||||
derivations.
|
||||
- Keep labels short enough to fit in narrow previews.
|
||||
|
||||
Schema violations exit non-zero with path-prefixed messages annotated with the
|
||||
element's id or label. The renderer additionally fails when it can detect
|
||||
layout problems, including missing stages, duplicate node IDs, nodes outside
|
||||
the readable diagram area, node overlap, labels colliding with nodes or other
|
||||
labels, labels wider than their node, unknown flow endpoints, missing flow
|
||||
labels, unreadably short flows, flows crossing unrelated nodes (2px Clean Flow
|
||||
clearance), or stages that exceed the viewBox. Stage frames remain intentional
|
||||
pass-through containers. Text width
|
||||
is estimated CJK-aware: fullwidth glyphs count as two units.
|
||||
|
||||
Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
|
||||
X crossings then fail with `composition/proper-crossing`; default `standard`
|
||||
keeps them as artifact-receipt warnings. Collinear stage corridors are outside
|
||||
the proper-X rule, but a separate gate warns in `standard` and fails in
|
||||
`showcase` when unrelated flows overlap for at least 8px. Shared semantic
|
||||
endpoints, point touches, and shorter overlaps remain valid. Showcase also
|
||||
rejects any route segment below 8px and any interior turn segment below 16px;
|
||||
ordinary 8–15px endpoint stubs remain valid.
|
||||
@@ -0,0 +1,483 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
|
||||
import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
|
||||
import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
|
||||
import { resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
|
||||
import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
|
||||
import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
|
||||
import { translateMessage as i18nText } from '../shared/i18n.mjs';
|
||||
import {
|
||||
asArray,
|
||||
isFinitePoint,
|
||||
rectsOverlap,
|
||||
cleanEndpointSideProblems,
|
||||
cleanFlowProblems,
|
||||
cleanCrossingProblems,
|
||||
cleanAmbiguousCorridorProblems,
|
||||
cleanBorderRunProblems,
|
||||
cleanRouteRhythmProblems,
|
||||
cleanLabelRouteClearanceProblems,
|
||||
suggestLabelObstacleFix,
|
||||
suggestLabelPairFix,
|
||||
anchor,
|
||||
automaticPortSpread,
|
||||
defaultFromSide,
|
||||
defaultToSide,
|
||||
chosenSide,
|
||||
polylinePath,
|
||||
routePointsValue,
|
||||
labelPoint,
|
||||
componentFill,
|
||||
componentText,
|
||||
arrowClassMap,
|
||||
variantAccent
|
||||
} from '../shared/geometry.mjs';
|
||||
|
||||
const nodeTextFit = {
|
||||
sublabelPreferred: 7,
|
||||
sublabelMinimum: 6,
|
||||
tagPreferred: 7,
|
||||
tagMinimum: 6,
|
||||
};
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const { diagram: dataflow, template, outPath } = await loadDiagramWithBrandMarks({
|
||||
rendererDir: __dirname,
|
||||
diagramType: 'dataflow',
|
||||
defaultExample: 'product-analytics.dataflow.json'
|
||||
});
|
||||
|
||||
const viewBox = dataflow.meta?.viewBox || [940, 720];
|
||||
const layout = {
|
||||
stageY: 46,
|
||||
stageH: 36,
|
||||
stageBottomPad: 74,
|
||||
leftX: 100,
|
||||
colGap: 215,
|
||||
stageW: 168,
|
||||
nodeW: 112,
|
||||
nodeH: 58,
|
||||
rowYs: [128, 242, 356, 470, 584],
|
||||
labelH: 16
|
||||
};
|
||||
|
||||
function flowLabelSize(flow) {
|
||||
const longestLine = Math.max(textUnits(flow.label), textUnits(flow.classification || ''));
|
||||
return {
|
||||
width: Math.round(Math.max(34, longestLine * 4.9 + 12) * 10) / 10,
|
||||
height: flow.classification ? 27 : layout.labelH,
|
||||
};
|
||||
}
|
||||
|
||||
function stageX(index) {
|
||||
return layout.leftX + index * layout.colGap;
|
||||
}
|
||||
|
||||
function stageFrame(stage, index) {
|
||||
return {
|
||||
id: index,
|
||||
label: stage.label,
|
||||
kind: 'stage',
|
||||
x: stageX(index) - layout.stageW / 2,
|
||||
y: layout.stageY,
|
||||
width: layout.stageW,
|
||||
height: viewBox[1] - layout.stageY - layout.stageBottomPad,
|
||||
radius: 10,
|
||||
};
|
||||
}
|
||||
|
||||
const compositionFrames = asArray(dataflow.stages).map(stageFrame);
|
||||
|
||||
function measureNode(node) {
|
||||
const width = node.width || layout.nodeW;
|
||||
const height = node.height || layout.nodeH;
|
||||
const cx = stageX(node.stage);
|
||||
const y = layout.rowYs[node.row] + (node.yOffset || 0);
|
||||
return {
|
||||
...node,
|
||||
width,
|
||||
height,
|
||||
cx,
|
||||
cy: y + height / 2,
|
||||
x: cx - width / 2,
|
||||
y
|
||||
};
|
||||
}
|
||||
|
||||
const nodes = new Map(asArray(dataflow.nodes).map((node) => [node.id, measureNode(node)]));
|
||||
const nodeSteps = new Map();
|
||||
for (const [index, flow] of asArray(dataflow.flows).entries()) {
|
||||
if (!nodeSteps.has(flow.from)) nodeSteps.set(flow.from, index);
|
||||
if (!nodeSteps.has(flow.to)) nodeSteps.set(flow.to, index + 1);
|
||||
}
|
||||
for (const [index, node] of asArray(dataflow.nodes).entries()) {
|
||||
if (!nodeSteps.has(node.id)) nodeSteps.set(node.id, index);
|
||||
}
|
||||
|
||||
function validateDataflow() {
|
||||
const problems = [];
|
||||
if (nodes.size !== asArray(dataflow.nodes).length) problems.push('Node ids must be unique.');
|
||||
|
||||
const stageCount = asArray(dataflow.stages).length;
|
||||
for (const node of nodes.values()) {
|
||||
if (typeof node.stage !== 'number' || node.stage < 0 || node.stage >= stageCount) {
|
||||
problems.push(`Node "${node.id}" uses invalid stage ${node.stage} — valid stages are 0..${stageCount - 1}.`);
|
||||
}
|
||||
if (typeof node.row !== 'number' || node.row < 0 || node.row >= layout.rowYs.length) {
|
||||
problems.push(`Node "${node.id}" uses invalid row ${node.row} — valid rows are 0..${layout.rowYs.length - 1}.`);
|
||||
}
|
||||
if (!isFinitePoint(node.x, node.y, node.cx, node.cy)) {
|
||||
problems.push(`Node "${node.id}" produced non-finite coordinates — check stage, row, width, height, and yOffset are numbers.`);
|
||||
continue;
|
||||
}
|
||||
if (node.x < 24 || node.x + node.width > viewBox[0] - 24) {
|
||||
problems.push(`Node "${node.id}" exceeds the horizontal bounds of the viewBox — reduce node.width or increase meta.viewBox[0].`);
|
||||
}
|
||||
if (node.y < layout.stageY + layout.stageH + 22 || node.y + node.height > viewBox[1] - layout.stageBottomPad) {
|
||||
problems.push(`Node "${node.id}" exceeds the readable diagram area — keep y between ${layout.stageY + layout.stageH + 22} and ${viewBox[1] - layout.stageBottomPad} (adjust row/yOffset or increase meta.viewBox[1]).`);
|
||||
}
|
||||
const estLabelW = textUnits(node.label) * 6.2;
|
||||
if (estLabelW > node.width + 6) {
|
||||
problems.push(`Label "${node.label}" (~${Math.round(estLabelW)}px) is wider than node "${node.id}" (${node.width}px) — shorten the label or increase node.width.`);
|
||||
}
|
||||
const brandRailProblem = brandTopRailProblem(node, node.width, 8);
|
||||
if (brandRailProblem) problems.push(brandRailProblem);
|
||||
// sublabel and tag render as single unwrapped <text> elements; shrink-to-fit
|
||||
// handles the ordinary case, this rejects what it cannot rescue.
|
||||
const availableTextW = availableNodeTextWidth(node.width);
|
||||
for (const [field, value, minimum] of [
|
||||
['Sublabel', node.sublabel, nodeTextFit.sublabelMinimum],
|
||||
['Tag', node.tag, nodeTextFit.tagMinimum],
|
||||
]) {
|
||||
if (!value) continue;
|
||||
const minimumW = minimumNodeTextWidth(value, minimum);
|
||||
if (minimumW > availableTextW) {
|
||||
problems.push(`${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but node "${node.id}" provides ${availableTextW}px — shorten the ${field.toLowerCase()} or increase node.width.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nodeList = asArray(dataflow.nodes);
|
||||
for (let i = 0; i < nodeList.length; i += 1) {
|
||||
for (let j = i + 1; j < nodeList.length; j += 1) {
|
||||
const a = nodes.get(nodeList[i].id);
|
||||
const b = nodes.get(nodeList[j].id);
|
||||
if (rectsOverlap(a, b, 10)) {
|
||||
problems.push(`Nodes "${a.id}" and "${b.id}" are less than 10px apart — move one to another stage/row or adjust yOffset.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const flow of asArray(dataflow.flows)) {
|
||||
if (!nodes.has(flow.from)) problems.push(`Flow "${flow.label || flow.from}" references unknown source "${flow.from}".`);
|
||||
if (!nodes.has(flow.to)) problems.push(`Flow "${flow.label || flow.to}" references unknown target "${flow.to}".`);
|
||||
if (!flow.label) problems.push(`Flow "${flow.from}" -> "${flow.to}" must include a short data label.`);
|
||||
if (nodes.has(flow.from) && nodes.has(flow.to)) {
|
||||
const routed = pathFor(flow);
|
||||
const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]];
|
||||
const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
|
||||
if (distance < 34) problems.push(`Flow "${flow.label}" is too short (${Math.round(distance)}px; minimum 34px) — route it through a channel or spread its nodes.`);
|
||||
if (Array.isArray(flow.via)) {
|
||||
for (let segmentIndex = 0; segmentIndex < routed.points.length - 1; segmentIndex += 1) {
|
||||
const segmentStart = routed.points[segmentIndex];
|
||||
const segmentEnd = routed.points[segmentIndex + 1];
|
||||
const isDiagonal = Math.abs(segmentStart[0] - segmentEnd[0]) > 0.01
|
||||
&& Math.abs(segmentStart[1] - segmentEnd[1]) > 0.01;
|
||||
if (!isDiagonal) continue;
|
||||
const viaIndex = Math.min(segmentIndex, flow.via.length - 1);
|
||||
problems.push(`Flow "${flow.label}" has a diagonal segment from (${segmentStart.join(', ')}) to (${segmentEnd.join(', ')}) — align via[${viaIndex}] with its adjacent point by sharing the same x or y coordinate.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
problems.push(...cleanEndpointSideProblems({
|
||||
relations: dataflow.flows,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
pathFor,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
fromSideFor: (flow) => flowSides(flow).fromSide,
|
||||
toSideFor: (flow) => flowSides(flow).toSide,
|
||||
routeHint: 'keep automatic routing, or choose fromSide/toSide and via points whose first and final segments cross node borders perpendicularly',
|
||||
}));
|
||||
problems.push(...cleanFlowProblems({
|
||||
relations: dataflow.flows,
|
||||
obstacles: nodes.values(),
|
||||
pathFor,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
obstacleKind: 'node',
|
||||
routeHint: 'adjust fromSide/toSide, set route/via or channelX/channelY, or move the node to another stage/row'
|
||||
}));
|
||||
problems.push(...cleanCrossingProblems({
|
||||
relations: dataflow.flows,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
pathFor,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
profile: dataflow.meta?.quality_profile,
|
||||
routeHint: 'adjust route/via or channelX/channelY so the flows use separate stage corridors'
|
||||
}));
|
||||
problems.push(...cleanAmbiguousCorridorProblems({
|
||||
relations: dataflow.flows,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
pathFor,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
profile: dataflow.meta?.quality_profile,
|
||||
routeHint: 'adjust route/via or channelX/channelY so unrelated flows do not visually merge'
|
||||
}));
|
||||
problems.push(...cleanBorderRunProblems({
|
||||
relations: dataflow.flows,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
frames: compositionFrames,
|
||||
pathFor,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
profile: dataflow.meta?.quality_profile,
|
||||
routeHint: 'adjust route/via or channelX/channelY so the flow crosses the stage perpendicularly instead of following its border'
|
||||
}));
|
||||
problems.push(...cleanRouteRhythmProblems({
|
||||
relations: dataflow.flows,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
pathFor,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
profile: dataflow.meta?.quality_profile,
|
||||
routeHint: 'adjust route/via or channelX/channelY so each turn uses a clear inter-stage corridor'
|
||||
}));
|
||||
|
||||
const labelRects = [];
|
||||
for (const [flowIndex, flow] of asArray(dataflow.flows).entries()) {
|
||||
if (!flow.label || !nodes.has(flow.from) || !nodes.has(flow.to)) continue;
|
||||
const [lx, ly] = labelPoint(flow, pathFor(flow).points);
|
||||
const { width, height } = flowLabelSize(flow);
|
||||
labelRects.push({ relation: flow, relationIndex: flowIndex, label: flow.label, x: lx - width / 2, y: ly - 11, width, height, lx, ly });
|
||||
}
|
||||
for (const rect of labelRects) {
|
||||
for (const node of nodes.values()) {
|
||||
if (rectsOverlap(rect, node, -2)) {
|
||||
problems.push(`Label "${rect.label}" overlaps node "${node.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, node, 'node')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < labelRects.length; i += 1) {
|
||||
for (let j = i + 1; j < labelRects.length; j += 1) {
|
||||
if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
|
||||
problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — adjust labelDx/labelDy.\n${suggestLabelPairFix(labelRects[i], labelRects[j])}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
problems.push(...cleanLabelRouteClearanceProblems({
|
||||
relations: dataflow.flows,
|
||||
labels: labelRects,
|
||||
endpointIds: new Set(nodes.keys()),
|
||||
pathFor,
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
profile: dataflow.meta?.quality_profile,
|
||||
routeHint: 'adjust labelAt, labelDx, labelDy, or labelSegment; otherwise adjust the other flow route/via/channelX/channelY'
|
||||
}));
|
||||
|
||||
const lastStageX = stageX(asArray(dataflow.stages).length - 1);
|
||||
if (lastStageX + layout.stageW / 2 > viewBox[0] - 24) {
|
||||
problems.push(`Stages exceed viewBox width — set meta.viewBox[0] to at least ${Math.ceil(lastStageX + layout.stageW / 2 + 24)}.`);
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
throwDiagnosticProblems('Data-flow layout validation failed', problems, {
|
||||
subject: { diagramType: 'dataflow' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function routeVia(flow, from, to, start, end) {
|
||||
if (flow.via) return flow.via;
|
||||
switch (flow.route || 'auto') {
|
||||
case 'straight':
|
||||
return [];
|
||||
case 'vertical-channel': {
|
||||
const x = flow.channelX ?? start[0] + (end[0] > start[0] ? 44 : -44);
|
||||
return [[x, start[1]], [x, end[1]]];
|
||||
}
|
||||
case 'bottom-channel': {
|
||||
const y = flow.channelY ?? Math.max(from.y + from.height, to.y + to.height) + 26;
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
case 'top-channel': {
|
||||
const y = flow.channelY ?? Math.min(from.y, to.y) - 24;
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
case 'auto':
|
||||
default: {
|
||||
if (Math.abs(start[1] - end[1]) < 4) return [];
|
||||
const midX = start[0] + (end[0] - start[0]) / 2;
|
||||
return [[midX, start[1]], [midX, end[1]]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pathCache = new Map();
|
||||
|
||||
function flowSides(flow) {
|
||||
const from = nodes.get(flow.from);
|
||||
const to = nodes.get(flow.to);
|
||||
return {
|
||||
fromSide: chosenSide(flow.fromSide, defaultFromSide(from, to)),
|
||||
toSide: chosenSide(flow.toSide, defaultToSide(from, to)),
|
||||
};
|
||||
}
|
||||
|
||||
const automaticPorts = automaticPortSpread(dataflow.flows, nodes, {
|
||||
sideFor: (flow, endpoint) => flowSides(flow)[endpoint === 'source' ? 'fromSide' : 'toSide'],
|
||||
});
|
||||
|
||||
function pathFor(flow) {
|
||||
if (pathCache.has(flow)) return pathCache.get(flow);
|
||||
const from = nodes.get(flow.from);
|
||||
const to = nodes.get(flow.to);
|
||||
const ports = automaticPorts.get(flow);
|
||||
const { fromSide, toSide } = flowSides(flow);
|
||||
const start = ports?.from || anchor(from, fromSide);
|
||||
const end = ports?.to || anchor(to, toSide);
|
||||
// Drop consecutive duplicate points so a purely vertical (or horizontal)
|
||||
// auto-route never emits a zero-length final segment — SVG derives
|
||||
// marker-end orientation from the last segment, and a degenerate segment
|
||||
// leaves the arrowhead angle undefined (see #169).
|
||||
const rawPoints = [start, ...routeVia(flow, from, to, start, end), end];
|
||||
const points = [];
|
||||
for (const p of rawPoints) {
|
||||
const prev = points.at(-1);
|
||||
if (!prev || Math.abs(p[0] - prev[0]) > 0.0001 || Math.abs(p[1] - prev[1]) > 0.0001) {
|
||||
points.push(p);
|
||||
}
|
||||
}
|
||||
// Guard against an all-degenerate route (e.g. start === end): keep both
|
||||
// endpoints so the path is still well-formed even if the marker is hidden.
|
||||
if (points.length < 2) points.push(end);
|
||||
const routed = { d: polylinePath(points), points };
|
||||
pathCache.set(flow, routed);
|
||||
return routed;
|
||||
}
|
||||
|
||||
function renderStage(stage, index) {
|
||||
const frame = compositionFrames[index];
|
||||
const cx = stageX(index);
|
||||
return ` <rect data-graph-role="structural-frame" data-composition-frame-kind="stage" data-composition-frame-id="${index}" x="${frame.x}" y="${frame.y}" width="${frame.width}" height="${frame.height}" rx="${frame.radius}" class="c-lane" stroke-width="1"/>
|
||||
<text x="${cx}" y="${layout.stageY + 22}" class="t-dim" font-size="9" font-weight="600" text-anchor="middle">${String(index + 1).padStart(2, '0')} / ${esc(stage.label)}</text>`;
|
||||
}
|
||||
|
||||
function renderNode(node) {
|
||||
const fill = componentFill[node.type] || 'c-external';
|
||||
const accent = componentText[node.type] || 't-muted';
|
||||
const hasSub = node.sublabel != null && node.sublabel !== '';
|
||||
const sub = hasSub
|
||||
? `\n <text data-detail="context" x="${node.cx}" y="${node.y + 37}" class="t-muted" font-size="${fittedNodeFontSize(node.sublabel, node.width, nodeTextFit.sublabelPreferred, nodeTextFit.sublabelMinimum)}" text-anchor="middle">${esc(node.sublabel)}</text>`
|
||||
: '';
|
||||
const tag = node.tag
|
||||
? `\n <text data-detail="fine" x="${node.cx}" y="${node.y + node.height - 11}" class="${accent}" font-size="${fittedNodeFontSize(node.tag, node.width, nodeTextFit.tagPreferred, nodeTextFit.tagMinimum)}" text-anchor="middle">${esc(node.tag)}</text>`
|
||||
: '';
|
||||
const stage = asArray(dataflow.stages)[node.stage];
|
||||
const context = stage
|
||||
? `${String(node.stage + 1).padStart(2, '0')} / ${stage.label}`
|
||||
: i18nText(dataflow.meta.locale, 'node.context.dataflow');
|
||||
const brand = renderBrandMark(node, { x: node.x + node.width - 22, y: node.y + 6 });
|
||||
const labelFontSize = fittedNodeFontSize(node.label, brandLabelFitWidth(node, node.width), 10, 8);
|
||||
const passport = { kind: node.type, sublabel: node.sublabel, tag: node.tag, context, ...brandMetadataFor(node) };
|
||||
return ` <g ${focusNodeAttrs(node.id, node.label, passport, dataflow.meta.locale)}>
|
||||
${focusNodeTitle(node.label, passport)}
|
||||
<rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="6" class="c-mask"/>
|
||||
<rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="6" class="${fill}"${animateAttr(dataflow.meta, 'node', nodeSteps.get(node.id))} stroke-width="1.5"/>
|
||||
${renderSemanticSigil(node.type, { x: node.x + 6, y: node.y + 6 })}${brand ? `\n ${brand}` : ''}
|
||||
<text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${node.cx}" y="${node.y + 21}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(node.label)}</text>${sub}${tag}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
function renderFlowPath(flow, index) {
|
||||
const [cls, marker] = arrowClassMap[flow.variant || 'default'] || arrowClassMap.default;
|
||||
const routed = pathFor(flow);
|
||||
const strokeWidth = flow.width || (flow.variant === 'emphasis' ? 1.8 : 1.4);
|
||||
return ` <path ${focusEdgeAttrs(flow.from, flow.to, flow.label, index, flow.id)} data-composition-points="${routePointsValue(routed.points)}" d="${routed.d}" class="${cls}"${animateAttr(dataflow.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
|
||||
}
|
||||
|
||||
function renderFlowLabel(flow, index) {
|
||||
const routed = pathFor(flow);
|
||||
const [lx, ly] = labelPoint(flow, routed.points);
|
||||
const { width: labelW, height: labelH } = flowLabelSize(flow);
|
||||
const classification = flow.classification
|
||||
? `\n <text data-detail="fine" x="${lx}" y="${ly + 11}" class="t-dim" font-size="7" text-anchor="middle">${esc(flow.classification)}</text>`
|
||||
: '';
|
||||
return ` <g data-detail="context" ${focusEdgeAttrs(flow.from, flow.to, flow.label, index, flow.id)}>
|
||||
<rect x="${lx - labelW / 2}" y="${ly - 11}" width="${labelW}" height="${labelH}" rx="4" class="c-mask"/>
|
||||
<text x="${lx}" y="${ly}" class="${variantAccent(flow.variant)}" font-size="8" text-anchor="middle">${esc(flow.label)}</text>${classification}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
const LEGEND_CATALOG = [
|
||||
{ kind: 'emphasis', className: 'a-emphasis', marker: 'arrowhead-emphasis', strokeWidth: 1.8, swatchWidth: 34, swatchGap: 9, interactive: false },
|
||||
{ kind: 'security', className: 'a-security', marker: 'arrowhead-security', swatchWidth: 34, swatchGap: 9, interactive: false },
|
||||
{ kind: 'dashed', className: 'a-dashed', marker: 'arrowhead-dashed', swatchWidth: 34, swatchGap: 9, interactive: false },
|
||||
{ kind: 'database' },
|
||||
{ kind: 'default', className: 'a-default', marker: 'arrowhead', swatchWidth: 34, swatchGap: 9, interactive: false },
|
||||
].map((entry) => ({
|
||||
...entry,
|
||||
label: i18nText(dataflow.meta.locale, `legend.dataflow.${entry.kind}`),
|
||||
}));
|
||||
|
||||
function renderLegend() {
|
||||
const presentKinds = new Set(asArray(dataflow.flows).map((flow) => flow.variant || 'default'));
|
||||
if ([...nodes.values()].some((node) => node.type === 'database')) presentKinds.add('database');
|
||||
const entries = resolveLegend(dataflow.meta?.legend, LEGEND_CATALOG, presentKinds);
|
||||
return renderResolvedLegend({
|
||||
entries,
|
||||
locale: dataflow.meta.locale,
|
||||
layout: {
|
||||
x: 40,
|
||||
baselineY: viewBox[1] - 36,
|
||||
width: viewBox[0] - 80,
|
||||
minTitleY: viewBox[1] - 66,
|
||||
unfit: dataflow.meta?.legend === undefined ? 'hide' : 'error',
|
||||
diagramType: 'dataflow',
|
||||
},
|
||||
renderSwatch: (entry) => entry.kind === 'database'
|
||||
? `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="c-database" stroke-width="1"/>`
|
||||
: `<path d="M ${entry.x} ${entry.baseline - 3} L ${entry.x + 34} ${entry.baseline - 3}" class="${entry.className}" stroke-width="${entry.strokeWidth || 1.4}" marker-end="url(#${entry.marker})"/>`,
|
||||
});
|
||||
}
|
||||
|
||||
function renderSvg() {
|
||||
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(dataflow.meta)}>
|
||||
${svgAccessibleText(dataflow.meta, 'dataflow')}
|
||||
${renderDefinitions()}
|
||||
|
||||
<!-- Background Grid -->
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
|
||||
<!-- Data Stages -->
|
||||
${dataflow.stages.map(renderStage).join('\n\n')}
|
||||
|
||||
<!-- Flow paths -->
|
||||
${asArray(dataflow.flows).map(renderFlowPath).join('\n')}
|
||||
|
||||
<!-- Nodes -->
|
||||
${[...nodes.values()].map(renderNode).join('\n\n')}
|
||||
|
||||
<!-- Flow labels -->
|
||||
${asArray(dataflow.flows).map(renderFlowLabel).join('\n')}
|
||||
|
||||
<!-- Legend -->
|
||||
${renderLegend()}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
validateDataflow();
|
||||
writeDiagram({
|
||||
outPath,
|
||||
template,
|
||||
diagramType: 'dataflow',
|
||||
meta: dataflow.meta,
|
||||
svg: renderSvg(),
|
||||
cards: dataflow.cards,
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
# Lifecycle Renderer
|
||||
|
||||
Render `diagram_type: "lifecycle"` JSON files into the standard Archify HTML
|
||||
template.
|
||||
|
||||
```bash
|
||||
node archify/renderers/lifecycle/render-lifecycle.mjs input.lifecycle.json output.html
|
||||
```
|
||||
|
||||
The renderer validates input against `archify/schemas/lifecycle.schema.json`
|
||||
with the bundled standalone validator. No dependency installation is required.
|
||||
|
||||
If `output.html` is omitted, the renderer uses `meta.output` from the JSON file
|
||||
or falls back to `lifecycle.html` in the current working directory.
|
||||
|
||||
## Input
|
||||
|
||||
Lifecycle JSON files must set:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "lifecycle",
|
||||
"meta": {
|
||||
"title": "Agent Run Lifecycle",
|
||||
"viewBox": [980, 660]
|
||||
},
|
||||
"lanes": [],
|
||||
"states": [],
|
||||
"transitions": [],
|
||||
"cards": []
|
||||
}
|
||||
```
|
||||
|
||||
Lane ids are semantic and reserved: a lane with id `main` is required and maps
|
||||
to the top phase band; `terminal` maps to the bottom outcome band; every other
|
||||
lane id (up to 4 lanes total) shares the single middle event band. The three
|
||||
band headers render from your lane labels — the middle band joins the labels of
|
||||
all event lanes with ` + `. A complete worked example lives at
|
||||
`archify/examples/agent-run.lifecycle.json`.
|
||||
|
||||
The schema lives at:
|
||||
|
||||
```text
|
||||
archify/schemas/lifecycle.schema.json
|
||||
```
|
||||
|
||||
## Legend
|
||||
|
||||
The default legend derives kinds from `states[].type`. Supported
|
||||
`meta.legend.entries` keys, in stable order, are `start`, `active`, `waiting`,
|
||||
`decision`, `success`, `failure`, `neutral`, and `external`. Labels and
|
||||
visibility may be overridden through the shared legend contract; only kinds
|
||||
backed by rendered states receive Semantic Legend controls.
|
||||
|
||||
## Layout budget
|
||||
|
||||
| Band | Lane id | Top y | Column centers | Default state |
|
||||
|------|---------|-------|----------------|---------------|
|
||||
| Phase | `main` (required) | 126 | `col` 0–4 → x = 94, 248, 402, 556, 710 | 118×62 |
|
||||
| Event | any other id | 278 | `col` 0–2 → x = 402, 556, 710 | 126×58 |
|
||||
| Outcome | `terminal` | 450 | `col` 0–2 → x = 402, 556, 710 | 118×58 |
|
||||
|
||||
Event and terminal columns are intentionally offset from the main rail:
|
||||
event/terminal `col: N` uses the same x coordinate as main `col: N + 2`.
|
||||
For example, lower-band columns 0, 1, and 2 align beneath main columns 2, 3,
|
||||
and 4 respectively.
|
||||
|
||||
| Constant | Value |
|
||||
|----------|-------|
|
||||
| viewBox | default `[980, 660]`; schema minimum `[420, 566]` |
|
||||
| State area | x within `[32, width − 32]`; state bottom at or above `height − 122` |
|
||||
| State spacing | ≥10px between any two states — checked across lanes, because all event lanes share one band; separate same-band states with `col` or `yOffset` |
|
||||
| Transition length | ≥32px between endpoints |
|
||||
| Legend row | final baseline y = height − 36; extra measured rows wrap upward |
|
||||
|
||||
The primary lifecycle rail runs along the phase band and extends to the
|
||||
furthest occupied phase column. Route presets for transitions: `straight`,
|
||||
`drop` (bend at `channelY`, defaulting to the vertical midpoint),
|
||||
`bottom-channel`, `top-channel`, `right-channel`, `left-channel`, explicit
|
||||
`via` points, or the default `auto`. Multi-segment transitions get rounded
|
||||
corners; tune them with `cornerRadius` (default 10, `0` for sharp bends).
|
||||
|
||||
## Design Rules
|
||||
|
||||
- Treat lifecycle diagrams as a phase map, not a dense state-transition graph.
|
||||
- Put the primary lifecycle on one horizontal rail using the `main` lane.
|
||||
- Use `step` labels for ordered phases, such as `01`, `02`, and `03`.
|
||||
- Use lower lanes only for interruptions, recovery, and terminal exits.
|
||||
- Keep transition labels out of the main SVG unless the label is essential;
|
||||
prefer node labels, tags, legend entries, and summary cards.
|
||||
- Avoid diagonal and crossing lines. Terminal exits should drop vertically from
|
||||
their source event whenever possible.
|
||||
- Use `success` for completion, `failure` for failure/terminal exits,
|
||||
`waiting` for pauses, and `decision` for quality gates.
|
||||
|
||||
Schema violations exit non-zero with path-prefixed messages annotated with the
|
||||
element's id or label. The renderer additionally fails when it can detect
|
||||
layout problems, including a missing `main` lane, duplicate state IDs, unknown
|
||||
lanes, unknown transition endpoints, states outside the lifecycle area,
|
||||
overlapping states (including across lanes), labels colliding with states or
|
||||
other labels, labels wider than their state, unreadably short transitions, or
|
||||
transitions crossing unrelated states (2px Clean Flow clearance). Lifecycle
|
||||
bands remain intentional pass-through containers.
|
||||
Text width is estimated CJK-aware: fullwidth glyphs count as two units.
|
||||
|
||||
Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
|
||||
X crossings then fail with `composition/proper-crossing`; default `standard`
|
||||
keeps them as artifact-receipt warnings. The final artifact check samples
|
||||
rounded `Q` corners. Collinear corridors remain outside the proper-X rule, but
|
||||
a separate gate warns in `standard` and fails in `showcase` when unrelated
|
||||
transitions overlap for at least 8px. Shared semantic endpoints, point touches,
|
||||
and shorter overlaps remain valid. Showcase also rejects any route segment
|
||||
below 8px and any interior turn segment below 16px; ordinary 8–15px endpoint
|
||||
stubs remain valid.
|
||||
@@ -0,0 +1,561 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
|
||||
import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
|
||||
import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
|
||||
import { resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
|
||||
import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
|
||||
import { brandLabelFitWidth, brandMarkFor, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
|
||||
import { translateMessage as i18nText } from '../shared/i18n.mjs';
|
||||
import {
|
||||
asArray,
|
||||
isFinitePoint,
|
||||
rectsOverlap,
|
||||
cleanEndpointSideProblems,
|
||||
cleanFlowProblems,
|
||||
cleanCrossingProblems,
|
||||
cleanAmbiguousCorridorProblems,
|
||||
cleanBorderRunProblems,
|
||||
cleanRouteRhythmProblems,
|
||||
cleanLabelRouteClearanceProblems,
|
||||
suggestLabelObstacleFix,
|
||||
suggestLabelPairFix,
|
||||
anchor,
|
||||
automaticPortSpread,
|
||||
defaultFromSide,
|
||||
defaultToSide,
|
||||
chosenSide,
|
||||
roundedPath,
|
||||
routePointsValue,
|
||||
labelPoint,
|
||||
arrowClassMap,
|
||||
variantAccent
|
||||
} from '../shared/geometry.mjs';
|
||||
|
||||
const stateTextFit = {
|
||||
sublabelPreferred: 7,
|
||||
sublabelMinimum: 6,
|
||||
tagPreferred: 7,
|
||||
tagMinimum: 6,
|
||||
};
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const { diagram: lifecycle, template, outPath } = await loadDiagramWithBrandMarks({
|
||||
rendererDir: __dirname,
|
||||
diagramType: 'lifecycle',
|
||||
defaultExample: 'agent-run.lifecycle.json'
|
||||
});
|
||||
|
||||
const viewBox = lifecycle.meta?.viewBox || [980, 660];
|
||||
const layout = {
|
||||
phaseY: 126,
|
||||
eventY: 278,
|
||||
outcomeY: 450,
|
||||
phaseW: 118,
|
||||
phaseH: 62,
|
||||
eventW: 126,
|
||||
eventH: 58,
|
||||
outcomeW: 118,
|
||||
outcomeH: 58,
|
||||
phaseXs: [94, 248, 402, 556, 710],
|
||||
eventXs: [402, 556, 710],
|
||||
outcomeXs: [402, 556, 710]
|
||||
};
|
||||
|
||||
const typeClass = {
|
||||
start: 'c-frontend',
|
||||
active: 'c-backend',
|
||||
waiting: 'c-cloud',
|
||||
decision: 'c-security',
|
||||
success: 'c-database',
|
||||
failure: 'c-security',
|
||||
neutral: 'c-external',
|
||||
external: 'c-external'
|
||||
};
|
||||
|
||||
const textClass = {
|
||||
start: 't-frontend',
|
||||
active: 't-backend',
|
||||
waiting: 't-cloud',
|
||||
decision: 't-security',
|
||||
success: 't-database',
|
||||
failure: 't-security',
|
||||
neutral: 't-muted',
|
||||
external: 't-muted'
|
||||
};
|
||||
|
||||
function legendY() {
|
||||
return viewBox[1] - 36;
|
||||
}
|
||||
|
||||
// Keep the authored state-placement contract independent from the measured
|
||||
// legend's lower baseline. Moving legend chrome must not admit new state
|
||||
// geometry into the reserved outcome/legend band.
|
||||
function lifecycleAreaBottom() {
|
||||
return viewBox[1] - 122;
|
||||
}
|
||||
|
||||
// Lane semantics are fixed: lane id "main" maps to the top phase band, lane id
|
||||
// "terminal" maps to the bottom outcome band, and every other lane shares the
|
||||
// middle event band (separated visually via yOffset).
|
||||
function bandFor(lane) {
|
||||
if (lane === 'main') return 'phase';
|
||||
if (lane === 'terminal') return 'outcome';
|
||||
return 'event';
|
||||
}
|
||||
|
||||
function measureState(state) {
|
||||
const isPhase = bandFor(state.lane) === 'phase';
|
||||
const isOutcome = bandFor(state.lane) === 'outcome';
|
||||
const width = state.width || (isPhase ? layout.phaseW : isOutcome ? layout.outcomeW : layout.eventW);
|
||||
const height = state.height || (isPhase ? layout.phaseH : isOutcome ? layout.outcomeH : layout.eventH);
|
||||
const xs = isPhase ? layout.phaseXs : isOutcome ? layout.outcomeXs : layout.eventXs;
|
||||
const cx = xs[state.col] ?? xs[xs.length - 1];
|
||||
const y = (
|
||||
isPhase ? layout.phaseY :
|
||||
isOutcome ? layout.outcomeY :
|
||||
layout.eventY
|
||||
) + (state.yOffset || 0);
|
||||
return {
|
||||
...state,
|
||||
width,
|
||||
height,
|
||||
x: cx - width / 2,
|
||||
y,
|
||||
cx,
|
||||
cy: y + height / 2
|
||||
};
|
||||
}
|
||||
|
||||
const states = new Map(asArray(lifecycle.states).map((state) => [state.id, measureState(state)]));
|
||||
const laneLabels = new Map(asArray(lifecycle.lanes).map((lane) => [lane.id, lane.label]));
|
||||
const stateSteps = new Map();
|
||||
for (const [index, transition] of asArray(lifecycle.transitions).entries()) {
|
||||
if (!stateSteps.has(transition.from)) stateSteps.set(transition.from, index);
|
||||
if (!stateSteps.has(transition.to)) stateSteps.set(transition.to, index + 1);
|
||||
}
|
||||
for (const [index, state] of asArray(lifecycle.states).entries()) {
|
||||
if (!stateSteps.has(state.id)) stateSteps.set(state.id, index);
|
||||
}
|
||||
|
||||
function validateLifecycle() {
|
||||
const problems = [];
|
||||
if (states.size !== asArray(lifecycle.states).length) problems.push('State ids must be unique.');
|
||||
|
||||
// The three bands are fixed at y=112/264/436. Preserve the original
|
||||
// outcome/legend reserve even though measured legend rows now sit lower.
|
||||
if (lifecycleAreaBottom() + 4 < 448) {
|
||||
problems.push(`viewBox height ${viewBox[1]} is too short for the fixed band layout — set meta.viewBox[1] to at least 566.`);
|
||||
}
|
||||
|
||||
const laneIds = new Set(asArray(lifecycle.lanes).map((lane) => lane.id));
|
||||
if (laneIds.size !== asArray(lifecycle.lanes).length) problems.push('Lane ids must be unique.');
|
||||
if (!laneIds.has('main')) {
|
||||
problems.push('Lifecycle diagrams need a lane with id "main" (the phase rail). Lane ids "main" and "terminal" are reserved: "main" maps to the top phase band, "terminal" to the bottom outcome band, and all other lanes share the middle event band.');
|
||||
}
|
||||
|
||||
for (const state of states.values()) {
|
||||
if (!laneIds.has(state.lane)) {
|
||||
problems.push(`State "${state.id}" uses unknown lane "${state.lane}".`);
|
||||
continue;
|
||||
}
|
||||
const band = bandFor(state.lane);
|
||||
const maxCol = band === 'phase'
|
||||
? layout.phaseXs.length
|
||||
: band === 'outcome'
|
||||
? layout.outcomeXs.length
|
||||
: layout.eventXs.length;
|
||||
if (!Number.isInteger(state.col) || state.col < 0 || state.col >= maxCol) {
|
||||
problems.push(`State "${state.id}" uses invalid column ${state.col} — the ${band} band has integer columns 0..${maxCol - 1}.`);
|
||||
continue;
|
||||
}
|
||||
if (!isFinitePoint(state.x, state.y, state.cx, state.cy)) {
|
||||
problems.push(`State "${state.id}" produced non-finite coordinates — check col, width, height, and yOffset are numbers.`);
|
||||
continue;
|
||||
}
|
||||
if (state.x < 32 || state.x + state.width > viewBox[0] - 32) {
|
||||
problems.push(`State "${state.id}" exceeds the horizontal bounds of the diagram — reduce state.width or increase meta.viewBox[0].`);
|
||||
}
|
||||
if (state.y < 64 || state.y + state.height > lifecycleAreaBottom()) {
|
||||
problems.push(`State "${state.id}" exceeds the vertical lifecycle area — keep y between 64 and ${lifecycleAreaBottom()} (adjust yOffset or increase meta.viewBox[1]).`);
|
||||
}
|
||||
const estLabelW = textUnits(state.label) * 6.2;
|
||||
if (estLabelW > state.width + 6) {
|
||||
problems.push(`Label "${state.label}" (~${Math.round(estLabelW)}px) is wider than state "${state.id}" (${state.width}px) — shorten the label or increase state.width.`);
|
||||
}
|
||||
const brandRailProblem = brandTopRailProblem(state, state.width, 8, 'State');
|
||||
if (brandRailProblem) problems.push(brandRailProblem);
|
||||
// sublabel and tag render as single unwrapped <text> elements; shrink-to-fit
|
||||
// handles the ordinary case, this rejects what it cannot rescue.
|
||||
const availableTextW = availableNodeTextWidth(state.width);
|
||||
for (const [field, value, minimum] of [
|
||||
['Sublabel', state.sublabel, stateTextFit.sublabelMinimum],
|
||||
['Tag', state.tag, stateTextFit.tagMinimum],
|
||||
]) {
|
||||
if (!value) continue;
|
||||
const minimumW = minimumNodeTextWidth(value, minimum);
|
||||
if (minimumW > availableTextW) {
|
||||
problems.push(`${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but state "${state.id}" provides ${availableTextW}px — shorten the ${field.toLowerCase()} or increase state.width.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All non-main/non-terminal lanes share the same y band, so the overlap
|
||||
// check must run across lanes — not per-lane.
|
||||
const allStates = [...states.values()];
|
||||
for (let i = 0; i < allStates.length; i += 1) {
|
||||
for (let j = i + 1; j < allStates.length; j += 1) {
|
||||
if (rectsOverlap(allStates[i], allStates[j], 10)) {
|
||||
problems.push(`States "${allStates[i].id}" and "${allStates[j].id}" are less than 10px apart — move one to another col or separate them with yOffset (lanes other than "main"/"terminal" share one band).`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const transition of asArray(lifecycle.transitions)) {
|
||||
if (!states.has(transition.from)) problems.push(`Transition "${transition.label || transition.from}" references unknown source "${transition.from}".`);
|
||||
if (!states.has(transition.to)) problems.push(`Transition "${transition.label || transition.to}" references unknown target "${transition.to}".`);
|
||||
if (states.has(transition.from) && states.has(transition.to)) {
|
||||
const routed = pathFor(transition);
|
||||
const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]];
|
||||
const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
|
||||
if (distance < 32) problems.push(`Transition "${transition.label || `${transition.from}->${transition.to}`}" is too short (${Math.round(distance)}px; minimum 32px) — route it through a channel or drop its label.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Authored via points are authoritative in schema v1, including under a
|
||||
// quality profile. Preserve and render them exactly: applying the endpoint
|
||||
// gate would either reject an existing typed input or require silently
|
||||
// falsifying its geometry. Automatic routes still receive the side gate.
|
||||
problems.push(...cleanEndpointSideProblems({
|
||||
relations: lifecycle.transitions,
|
||||
endpointIds: new Set(states.keys()),
|
||||
pathFor,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
fromSideFor: (transition) => transitionSides(transition).fromSide,
|
||||
toSideFor: (transition) => transitionSides(transition).toSide,
|
||||
shouldCheckRelation: (transition) => !Array.isArray(transition.via),
|
||||
routeHint: 'keep automatic routing, or choose fromSide/toSide and via points whose first and final segments cross state borders perpendicularly',
|
||||
}));
|
||||
problems.push(...cleanFlowProblems({
|
||||
relations: lifecycle.transitions,
|
||||
obstacles: states.values(),
|
||||
pathFor,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
obstacleKind: 'state',
|
||||
routeHint: 'adjust fromSide/toSide, set route/via or channelX/channelY, or move the state with col/yOffset'
|
||||
}));
|
||||
problems.push(...cleanCrossingProblems({
|
||||
relations: lifecycle.transitions,
|
||||
endpointIds: new Set(states.keys()),
|
||||
pathFor,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
profile: lifecycle.meta?.quality_profile,
|
||||
routeHint: 'adjust route/via or channelX/channelY so the transitions use separate lifecycle corridors'
|
||||
}));
|
||||
problems.push(...cleanAmbiguousCorridorProblems({
|
||||
relations: lifecycle.transitions,
|
||||
endpointIds: new Set(states.keys()),
|
||||
pathFor,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
profile: lifecycle.meta?.quality_profile,
|
||||
routeHint: 'adjust route/via or channelX/channelY so unrelated transitions do not visually merge'
|
||||
}));
|
||||
// Lifecycle bands are dashed reading guides, not closed containers. Keep the
|
||||
// shared contract wired with an explicit empty frame set so future typed
|
||||
// lifecycle containers cannot accidentally inherit presentation geometry.
|
||||
problems.push(...cleanBorderRunProblems({
|
||||
relations: lifecycle.transitions,
|
||||
endpointIds: new Set(states.keys()),
|
||||
frames: [],
|
||||
pathFor,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
profile: lifecycle.meta?.quality_profile
|
||||
}));
|
||||
problems.push(...cleanRouteRhythmProblems({
|
||||
relations: lifecycle.transitions,
|
||||
endpointIds: new Set(states.keys()),
|
||||
pathFor,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
profile: lifecycle.meta?.quality_profile,
|
||||
routeHint: 'move route/via or channel coordinates so each lifecycle turn has a readable run-up'
|
||||
}));
|
||||
|
||||
const labelRects = [];
|
||||
for (const [transitionIndex, transition] of asArray(lifecycle.transitions).entries()) {
|
||||
if (!transition.label || !states.has(transition.from) || !states.has(transition.to)) continue;
|
||||
const [lx, ly] = labelPoint(transition, pathFor(transition).points);
|
||||
const longestLine = Math.max(textUnits(transition.label), textUnits(transition.note || ''));
|
||||
const width = Math.max(32, longestLine * 4.9 + 12);
|
||||
const height = transition.note ? 27 : 16;
|
||||
labelRects.push({ relation: transition, relationIndex: transitionIndex, label: transition.label, x: lx - width / 2, y: ly - 11, width, height, lx, ly });
|
||||
}
|
||||
for (const rect of labelRects) {
|
||||
for (const state of states.values()) {
|
||||
if (rectsOverlap(rect, state, -2)) {
|
||||
problems.push(`Label "${rect.label}" overlaps state "${state.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, state, 'state')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < labelRects.length; i += 1) {
|
||||
for (let j = i + 1; j < labelRects.length; j += 1) {
|
||||
if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
|
||||
problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — adjust labelDx/labelDy.\n${suggestLabelPairFix(labelRects[i], labelRects[j])}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
problems.push(...cleanLabelRouteClearanceProblems({
|
||||
relations: lifecycle.transitions,
|
||||
labels: labelRects,
|
||||
endpointIds: new Set(states.keys()),
|
||||
pathFor,
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
profile: lifecycle.meta?.quality_profile,
|
||||
}));
|
||||
|
||||
if (problems.length) {
|
||||
throwDiagnosticProblems('Lifecycle layout validation failed', problems, {
|
||||
subject: { diagramType: 'lifecycle' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function routeVia(transition, from, to, start, end, fromSide, toSide) {
|
||||
if (transition.via) return transition.via;
|
||||
switch (transition.route || 'auto') {
|
||||
case 'straight':
|
||||
return [];
|
||||
case 'drop': {
|
||||
const y = transition.channelY ?? (start[1] + end[1]) / 2;
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
case 'bottom-channel': {
|
||||
const y = transition.channelY ?? Math.max(from.y + from.height, to.y + to.height) + 34;
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
case 'top-channel': {
|
||||
const y = transition.channelY ?? Math.min(from.y, to.y) - 28;
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
case 'right-channel': {
|
||||
const x = transition.channelX ?? Math.max(from.x + from.width, to.x + to.width) + 36;
|
||||
return [[x, start[1]], [x, end[1]]];
|
||||
}
|
||||
case 'left-channel': {
|
||||
const x = transition.channelX ?? Math.min(from.x, to.x) - 36;
|
||||
return [[x, start[1]], [x, end[1]]];
|
||||
}
|
||||
case 'auto':
|
||||
default: {
|
||||
if (start[0] === end[0] || start[1] === end[1]) return [];
|
||||
const fromVertical = fromSide === 'top' || fromSide === 'bottom';
|
||||
const toVertical = toSide === 'top' || toSide === 'bottom';
|
||||
if (fromVertical !== toVertical) {
|
||||
return [fromVertical ? [start[0], end[1]] : [end[0], start[1]]];
|
||||
}
|
||||
if (fromVertical) {
|
||||
const y = transition.channelY ?? (start[1] + end[1]) / 2;
|
||||
return [[start[0], y], [end[0], y]];
|
||||
}
|
||||
const x = transition.channelX ?? (start[0] + end[0]) / 2;
|
||||
return [[x, start[1]], [x, end[1]]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pathCache = new Map();
|
||||
|
||||
function transitionSides(transition) {
|
||||
const from = states.get(transition.from);
|
||||
const to = states.get(transition.to);
|
||||
return {
|
||||
fromSide: chosenSide(transition.fromSide, defaultFromSide(from, to)),
|
||||
toSide: chosenSide(transition.toSide, defaultToSide(from, to)),
|
||||
};
|
||||
}
|
||||
|
||||
const automaticPorts = automaticPortSpread(lifecycle.transitions, states, {
|
||||
sideFor: (transition, endpoint) => transitionSides(transition)[endpoint === 'source' ? 'fromSide' : 'toSide'],
|
||||
});
|
||||
|
||||
function pathFor(transition) {
|
||||
if (pathCache.has(transition)) return pathCache.get(transition);
|
||||
const from = states.get(transition.from);
|
||||
const to = states.get(transition.to);
|
||||
const ports = automaticPorts.get(transition);
|
||||
const { fromSide, toSide } = transitionSides(transition);
|
||||
const start = ports?.from || anchor(from, fromSide);
|
||||
const end = ports?.to || anchor(to, toSide);
|
||||
let via = routeVia(transition, from, to, start, end, fromSide, toSide);
|
||||
if (ports && !via.length && Math.abs(start[0] - end[0]) >= 4 && Math.abs(start[1] - end[1]) >= 4) {
|
||||
const midX = (start[0] + end[0]) / 2;
|
||||
via = [[midX, start[1]], [midX, end[1]]];
|
||||
}
|
||||
const points = [start, ...via, end];
|
||||
const routed = {
|
||||
d: roundedPath(points, transition.cornerRadius ?? 10),
|
||||
points
|
||||
};
|
||||
pathCache.set(transition, routed);
|
||||
return routed;
|
||||
}
|
||||
|
||||
function bandTitles() {
|
||||
const lanes = asArray(lifecycle.lanes);
|
||||
const mainLane = lanes.find((lane) => lane.id === 'main');
|
||||
const terminalLane = lanes.find((lane) => lane.id === 'terminal');
|
||||
const eventLanes = lanes.filter((lane) => lane.id !== 'main' && lane.id !== 'terminal');
|
||||
return [
|
||||
mainLane?.label || 'Lifecycle phases',
|
||||
eventLanes.length ? eventLanes.map((lane) => lane.label).join(' + ') : 'Interruptions + recovery',
|
||||
terminalLane?.label || 'Outcomes'
|
||||
];
|
||||
}
|
||||
|
||||
function renderBands() {
|
||||
const right = viewBox[0] - 72;
|
||||
const titles = bandTitles();
|
||||
return ` <path d="M 72 112 L ${right} 112" class="a-default" stroke-width="0.8" stroke-dasharray="3,8"/>
|
||||
<text x="72" y="100" class="t-dim" font-size="10" font-weight="600">01 / ${esc(titles[0])}</text>
|
||||
<path d="M 72 264 L ${right} 264" class="a-default" stroke-width="0.8" stroke-dasharray="3,8"/>
|
||||
<text x="72" y="252" class="t-dim" font-size="10" font-weight="600">02 / ${esc(titles[1])}</text>
|
||||
<path d="M 72 436 L ${right} 436" class="a-default" stroke-width="0.8" stroke-dasharray="3,8"/>
|
||||
<text x="72" y="424" class="t-dim" font-size="10" font-weight="600">03 / ${esc(titles[2])}</text>`;
|
||||
}
|
||||
|
||||
function renderState(state) {
|
||||
const fill = typeClass[state.type] || typeClass.neutral;
|
||||
const accent = textClass[state.type] || 't-muted';
|
||||
const hasSub = state.sublabel != null && state.sublabel !== '';
|
||||
const sub = hasSub
|
||||
? `\n <text data-detail="context" x="${state.cx}" y="${state.y + 37}" class="t-muted" font-size="${fittedNodeFontSize(state.sublabel, state.width, stateTextFit.sublabelPreferred, stateTextFit.sublabelMinimum)}" text-anchor="middle">${esc(state.sublabel)}</text>`
|
||||
: '';
|
||||
const tag = state.tag
|
||||
? `\n <text data-detail="fine" x="${state.cx}" y="${state.y + state.height - 11}" class="${accent}" font-size="${fittedNodeFontSize(state.tag, state.width, stateTextFit.tagPreferred, stateTextFit.tagMinimum)}" text-anchor="middle">${esc(state.tag)}</text>`
|
||||
: '';
|
||||
const hasBrand = Boolean(brandMarkFor(state));
|
||||
const step = state.step
|
||||
? `\n <text data-detail="fine" x="${state.x + (hasBrand ? 23 : 10)}" y="${state.y + 14}" class="${accent}" font-size="7" font-weight="700">${esc(state.step)}</text>`
|
||||
: '';
|
||||
const brand = renderBrandMark(state, { x: state.x + state.width - 22, y: state.y + 6 });
|
||||
const labelFontSize = fittedNodeFontSize(state.label, brandLabelFitWidth(state, state.width), 10, 8);
|
||||
const passport = {
|
||||
kind: state.type,
|
||||
sublabel: state.sublabel,
|
||||
tag: state.tag,
|
||||
context: laneLabels.get(state.lane) || i18nText(lifecycle.meta.locale, 'node.context.lifecycle'),
|
||||
...brandMetadataFor(state),
|
||||
};
|
||||
return ` <g ${focusNodeAttrs(state.id, state.label, passport, lifecycle.meta.locale)}>
|
||||
${focusNodeTitle(state.label, passport)}
|
||||
<rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="7" class="c-mask"/>
|
||||
<rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="7" class="${fill}"${animateAttr(lifecycle.meta, 'node', stateSteps.get(state.id))} stroke-width="1.5"/>
|
||||
${renderSemanticSigil(state.type, { x: hasBrand ? state.x + 6 : state.x + state.width - 17, y: state.y + 6 })}${brand ? `\n ${brand}` : ''}${step}
|
||||
<text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${state.cx}" y="${state.y + 21}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(state.label)}</text>${sub}${tag}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
function renderTransitionPath(transition, index) {
|
||||
const [cls, marker] = arrowClassMap[transition.variant || 'default'] || arrowClassMap.default;
|
||||
const routed = pathFor(transition);
|
||||
const strokeWidth = transition.width || (transition.variant === 'emphasis' ? 2 : 1.1);
|
||||
return ` <path ${focusEdgeAttrs(transition.from, transition.to, transition.label, index, transition.id)} data-composition-points="${routePointsValue(routed.points)}" d="${routed.d}" class="${cls}"${animateAttr(lifecycle.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
|
||||
}
|
||||
|
||||
function renderTransitionLabel(transition, index) {
|
||||
if (!transition.label) return '';
|
||||
const routed = pathFor(transition);
|
||||
const [lx, ly] = labelPoint(transition, routed.points);
|
||||
const longestLine = Math.max(textUnits(transition.label), textUnits(transition.note || ''));
|
||||
const labelW = Math.max(32, longestLine * 4.9 + 12);
|
||||
const labelH = transition.note ? 27 : 16;
|
||||
const note = transition.note
|
||||
? `\n <text data-detail="fine" x="${lx}" y="${ly + 11}" class="t-dim" font-size="7" text-anchor="middle">${esc(transition.note)}</text>`
|
||||
: '';
|
||||
return ` <g data-detail="context" ${focusEdgeAttrs(transition.from, transition.to, transition.label, index, transition.id)}>
|
||||
<rect x="${lx - labelW / 2}" y="${ly - 11}" width="${labelW}" height="${labelH}" rx="4" class="c-mask"/>
|
||||
<text x="${lx}" y="${ly}" class="${variantAccent(transition.variant)}" font-size="8" text-anchor="middle">${esc(transition.label)}</text>${note}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
const LEGEND_CATALOG = [
|
||||
'start',
|
||||
'active',
|
||||
'waiting',
|
||||
'decision',
|
||||
'success',
|
||||
'failure',
|
||||
'neutral',
|
||||
'external',
|
||||
].map((kind) => ({ kind, label: i18nText(lifecycle.meta.locale, `legend.lifecycle.${kind}`) }));
|
||||
|
||||
function renderLegend() {
|
||||
const presentKinds = new Set([...states.values()].map((state) => state.type));
|
||||
const entries = resolveLegend(lifecycle.meta?.legend, LEGEND_CATALOG, presentKinds);
|
||||
return renderResolvedLegend({
|
||||
entries,
|
||||
locale: lifecycle.meta.locale,
|
||||
layout: {
|
||||
x: 40,
|
||||
baselineY: legendY(),
|
||||
width: viewBox[0] - 80,
|
||||
minTitleY: lifecycleAreaBottom() + 8,
|
||||
unfit: lifecycle.meta?.legend === undefined ? 'hide' : 'error',
|
||||
diagramType: 'lifecycle',
|
||||
},
|
||||
renderSwatch: (entry) => `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="${typeClass[entry.kind] || 'c-external'}" stroke-width="1"/>`,
|
||||
});
|
||||
}
|
||||
|
||||
function renderLifecycleRail() {
|
||||
const mainCols = [...states.values()]
|
||||
.filter((state) => bandFor(state.lane) === 'phase')
|
||||
.map((state) => state.col);
|
||||
if (!mainCols.length) return '';
|
||||
const railEnd = layout.phaseXs[Math.max(...mainCols)] + 38;
|
||||
return ` <path d="M 154 ${layout.phaseY + 31} L ${railEnd} ${layout.phaseY + 31}" class="a-emphasis" stroke-width="2.2" marker-end="url(#arrowhead-emphasis)"/>`;
|
||||
}
|
||||
|
||||
function renderSvg() {
|
||||
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(lifecycle.meta)}>
|
||||
${svgAccessibleText(lifecycle.meta, 'lifecycle')}
|
||||
${renderDefinitions()}
|
||||
|
||||
<!-- Background Grid -->
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
|
||||
<!-- Lifecycle bands -->
|
||||
${renderBands()}
|
||||
|
||||
<!-- Primary lifecycle rail -->
|
||||
${renderLifecycleRail()}
|
||||
|
||||
<!-- Transition paths -->
|
||||
${asArray(lifecycle.transitions).map(renderTransitionPath).join('\n')}
|
||||
|
||||
<!-- States -->
|
||||
${[...states.values()].map(renderState).join('\n\n')}
|
||||
|
||||
<!-- Transition labels -->
|
||||
${asArray(lifecycle.transitions).map(renderTransitionLabel).join('\n')}
|
||||
|
||||
<!-- Legend -->
|
||||
${renderLegend()}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
validateLifecycle();
|
||||
writeDiagram({
|
||||
outPath,
|
||||
template,
|
||||
diagramType: 'lifecycle',
|
||||
meta: lifecycle.meta,
|
||||
svg: renderSvg(),
|
||||
cards: lifecycle.cards,
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
# Sequence Renderer
|
||||
|
||||
Render `diagram_type: "sequence"` JSON files into the standard Archify HTML
|
||||
template.
|
||||
|
||||
```bash
|
||||
node archify/renderers/sequence/render-sequence.mjs input.sequence.json output.html
|
||||
```
|
||||
|
||||
The renderer validates input against `archify/schemas/sequence.schema.json`
|
||||
with the bundled standalone validator. No dependency installation is required.
|
||||
|
||||
If `output.html` is omitted, the renderer uses `meta.output` from the JSON file
|
||||
or falls back to `sequence.html` in the current working directory.
|
||||
|
||||
## Input
|
||||
|
||||
Sequence JSON files must set:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "sequence",
|
||||
"meta": {
|
||||
"title": "Cache Miss Request Sequence",
|
||||
"viewBox": [920, 760]
|
||||
},
|
||||
"participants": [],
|
||||
"segments": [],
|
||||
"messages": [],
|
||||
"activations": [],
|
||||
"cards": []
|
||||
}
|
||||
```
|
||||
|
||||
The timeline scales with the viewBox height: a taller `meta.viewBox` buys more
|
||||
message room, a shorter one shrinks the readable band instead of clipping. A
|
||||
complete worked example lives at
|
||||
`archify/examples/cache-miss-request.sequence.json`.
|
||||
|
||||
The schema lives at:
|
||||
|
||||
```text
|
||||
archify/schemas/sequence.schema.json
|
||||
```
|
||||
|
||||
## Legend
|
||||
|
||||
The default visual legend derives kinds from `messages[].variant` (omitting
|
||||
`variant` means `default`). Supported `meta.legend.entries` keys, in stable
|
||||
order, are `emphasis`, `return`, `security`, `dashed`, and `default`. These are
|
||||
visual message keys, not Semantic Lens controls; label/visibility overrides do
|
||||
not create edge facts.
|
||||
|
||||
## Layout budget
|
||||
|
||||
| Constant | Value |
|
||||
|----------|-------|
|
||||
| viewBox | default `[920, 760]`; schema minimum `[480, 480]` |
|
||||
| Participant boxes | `fixed` (default): 86×54 at y 72; `spread`: viewBox-relative width from 86px up to 190px |
|
||||
| Participant columns | `fixed`: centers at x = 62 + index×108; `spread`: columns distribute across the available viewBox width |
|
||||
| Participant count | the last box must end at or before width − 40; layouts that cannot fit fail closed |
|
||||
| Lifelines | from y 142 down to height − 65; band must be ≥120px tall |
|
||||
| Message `y` range | `[160, height − 83]` |
|
||||
| Message spacing | ≥28px vertical between messages that share horizontal space |
|
||||
| Arrow span | ≥60px horizontal between the two participants |
|
||||
| Segments | y pixel ranges with `to > from`, inside `[72, lifeline bottom + 20]` |
|
||||
| Legend row | y = height − 54 |
|
||||
|
||||
`segments[].from/to` and `activations[].from/to` are y pixel coordinates, not
|
||||
participant ids; activations also require `to > from`.
|
||||
|
||||
### Column fit
|
||||
|
||||
Sequence diagrams use `meta.column_fit: "fixed"` by default so existing
|
||||
documents keep their historical coordinates. Use `"spread"` when a wide
|
||||
viewBox would otherwise leave empty space on the right or when meaningful
|
||||
participant labels do not fit the fixed 86px boxes. Spread derives box width
|
||||
and column distance from the viewBox while preserving participant order,
|
||||
lifelines, and message semantics.
|
||||
|
||||
## Design Rules
|
||||
|
||||
- Put participants across the top, ordered by the story the reader should
|
||||
follow.
|
||||
- Time moves downward.
|
||||
- Use `emphasis` for the main request path.
|
||||
- Use `security` for auth, consent, permission, and policy calls.
|
||||
- Use `return` for quiet response messages.
|
||||
- Use `dashed` for async trace, event, logging, and non-blocking work.
|
||||
- Use segments as light background guides; keep segment labels short.
|
||||
- Keep labels concise, but try `meta.column_fit: "spread"` before shortening a
|
||||
meaningful participant label just to fit the fixed boxes.
|
||||
|
||||
Schema violations exit non-zero with path-prefixed messages annotated with the
|
||||
element's id or label. The renderer additionally fails when it can detect
|
||||
layout problems, including missing participants, duplicate participant IDs,
|
||||
participant labels wider than their box, unknown message endpoints, messages
|
||||
outside the readable timeline, overly tight vertical spacing between messages
|
||||
that overlap horizontally, invalid segment or activation ranges, or
|
||||
participants that exceed the viewBox. The shared Clean Flow contract treats
|
||||
participant headers as semantic boxes while explicitly allowing messages to
|
||||
cross intermediate lifelines, activation bars, and segment frames. Text width is estimated CJK-aware:
|
||||
fullwidth glyphs count as two units.
|
||||
|
||||
Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
|
||||
message X crossings then fail with `composition/proper-crossing`; default
|
||||
`standard` keeps them as artifact-receipt warnings. Messages may still cross
|
||||
intermediate lifelines. Collinear corridors remain outside the proper-X rule,
|
||||
but a separate gate warns in `standard` and fails in `showcase` when unrelated
|
||||
messages overlap for at least 8px. Shared semantic endpoints, point touches,
|
||||
and shorter overlaps remain valid. Showcase also rejects any route segment
|
||||
below 8px and any interior turn segment below 16px; ordinary 8–15px endpoint
|
||||
stubs remain valid.
|
||||
@@ -0,0 +1,453 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
|
||||
import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
|
||||
import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
|
||||
import { resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
|
||||
import { componentFill, arrowClassMap, rectsOverlap, cleanFlowProblems, cleanCrossingProblems, cleanAmbiguousCorridorProblems, cleanBorderRunProblems, cleanRouteRhythmProblems, cleanLabelRouteClearanceProblems, routePointsValue, asArray, isFinitePoint } from '../shared/geometry.mjs';
|
||||
import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
|
||||
import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
|
||||
import { translateMessage as i18nText } from '../shared/i18n.mjs';
|
||||
|
||||
const participantTextFit = {
|
||||
sublabelPreferred: 7,
|
||||
sublabelMinimum: 6,
|
||||
};
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const { diagram: sequence, template, outPath } = await loadDiagramWithBrandMarks({
|
||||
rendererDir: __dirname,
|
||||
diagramType: 'sequence',
|
||||
defaultExample: 'cache-miss-request.sequence.json'
|
||||
});
|
||||
|
||||
const viewBox = sequence.meta?.viewBox || [920, 760];
|
||||
// The timeline scales with viewBox height: a taller viewBox gains message room,
|
||||
// a shorter one shrinks the readable band (validated below) instead of clipping.
|
||||
// `column_fit: "spread"` widens the lanes with the viewBox instead of keeping
|
||||
// the fixed 108px gap, so a wide canvas gains column distance and label room
|
||||
// rather than dead space on the right. The default stays "fixed" so existing
|
||||
// diagrams keep their coordinates.
|
||||
const columnFit = sequence.meta?.column_fit === 'spread' ? 'spread' : 'fixed';
|
||||
const participantCount = Math.max(1, asArray(sequence.participants).length);
|
||||
const sideMargin = 62;
|
||||
const participantW = columnFit === 'spread'
|
||||
? Math.max(86, Math.min(190, Math.round((viewBox[0] - sideMargin * 2) / participantCount) - 24))
|
||||
: 86;
|
||||
const colGap = columnFit === 'spread' && participantCount > 1
|
||||
? Math.max(108, (viewBox[0] - 40 - sideMargin - participantW) / (participantCount - 1))
|
||||
: 108;
|
||||
|
||||
const layout = {
|
||||
topY: 72,
|
||||
participantW,
|
||||
participantH: 54,
|
||||
lifelineTop: 142,
|
||||
lifelineBottom: viewBox[1] - 65,
|
||||
legendY: viewBox[1] - 54,
|
||||
leftX: columnFit === 'spread' ? sideMargin + participantW / 2 : sideMargin,
|
||||
colGap,
|
||||
labelH: 16
|
||||
};
|
||||
|
||||
const participantBoxWidthNote = columnFit === 'spread'
|
||||
? `participant boxes are ${participantW}px for this viewBox width and ${participantCount} participants`
|
||||
: `participant boxes are a fixed ${participantW}px unless meta.column_fit is "spread"`;
|
||||
|
||||
const arrowClass = {
|
||||
...arrowClassMap,
|
||||
return: ['a-default', 'arrowhead']
|
||||
};
|
||||
|
||||
function participantX(index) {
|
||||
return layout.leftX + index * layout.colGap;
|
||||
}
|
||||
|
||||
const participants = new Map(asArray(sequence.participants).map((participant, index) => [
|
||||
participant.id,
|
||||
{
|
||||
...participant,
|
||||
index,
|
||||
cx: participantX(index),
|
||||
x: participantX(index) - layout.participantW / 2,
|
||||
y: layout.topY,
|
||||
width: layout.participantW,
|
||||
height: layout.participantH,
|
||||
cy: layout.topY + layout.participantH / 2
|
||||
}
|
||||
]));
|
||||
|
||||
function messageGeometry(message) {
|
||||
const from = participants.get(message.from);
|
||||
const to = participants.get(message.to);
|
||||
if (!from || !to || typeof message.y !== 'number') return null;
|
||||
const direction = to.cx > from.cx ? 1 : -1;
|
||||
const start = from.cx + direction * 7;
|
||||
const end = to.cx - direction * 7;
|
||||
return { start, end, center: (start + end) / 2 };
|
||||
}
|
||||
|
||||
function messageLabelBox(message, relationIndex = null) {
|
||||
const geometry = messageGeometry(message);
|
||||
if (!geometry) return null;
|
||||
const width = Math.max(34, textUnits(message.label) * 5.2 + 12);
|
||||
return {
|
||||
relation: message,
|
||||
relationIndex,
|
||||
label: message.label,
|
||||
x: geometry.center - width / 2,
|
||||
y: message.y - 20,
|
||||
width,
|
||||
height: layout.labelH,
|
||||
};
|
||||
}
|
||||
|
||||
function messageRouteBox(message) {
|
||||
const geometry = messageGeometry(message);
|
||||
if (!geometry) return null;
|
||||
return {
|
||||
x: Math.min(geometry.start, geometry.end),
|
||||
y: message.y - 2,
|
||||
width: Math.abs(geometry.end - geometry.start),
|
||||
height: 4,
|
||||
};
|
||||
}
|
||||
|
||||
const compositionFrames = asArray(sequence.segments).map((segment, index) => ({
|
||||
id: index,
|
||||
label: segment.label,
|
||||
kind: 'segment',
|
||||
x: 48,
|
||||
y: segment.from,
|
||||
width: viewBox[0] - 96,
|
||||
height: segment.to - segment.from,
|
||||
radius: 10,
|
||||
}));
|
||||
|
||||
function messagePath(message) {
|
||||
return {
|
||||
points: participants.has(message.from) && participants.has(message.to)
|
||||
? [[participants.get(message.from).cx, message.y], [participants.get(message.to).cx, message.y]]
|
||||
: []
|
||||
};
|
||||
}
|
||||
|
||||
function validateSequence() {
|
||||
const problems = [];
|
||||
if (participants.size !== asArray(sequence.participants).length) problems.push('Participant ids must be unique.');
|
||||
|
||||
if (layout.lifelineBottom - layout.lifelineTop < 120) {
|
||||
problems.push(`viewBox height ${viewBox[1]} leaves under 120px of timeline — set meta.viewBox[1] to at least ${layout.lifelineTop + 120 + 65}.`);
|
||||
}
|
||||
|
||||
for (const participant of participants.values()) {
|
||||
const estLabelW = textUnits(participant.label) * 6.8;
|
||||
if (estLabelW > layout.participantW + 6) {
|
||||
problems.push(`Label "${participant.label}" (~${Math.round(estLabelW)}px) is wider than the ${layout.participantW}px participant box — shorten it.`);
|
||||
}
|
||||
const brandRailProblem = brandTopRailProblem(participant, layout.participantW, 8, 'Participant');
|
||||
if (brandRailProblem) problems.push(brandRailProblem);
|
||||
// sublabel renders as a single unwrapped <text>; shrink-to-fit handles the
|
||||
// ordinary case, this rejects what it cannot rescue.
|
||||
if (participant.sublabel) {
|
||||
const availableTextW = availableNodeTextWidth(layout.participantW);
|
||||
const minimumW = minimumNodeTextWidth(participant.sublabel, participantTextFit.sublabelMinimum);
|
||||
if (minimumW > availableTextW) {
|
||||
problems.push(`Sublabel "${participant.sublabel}" needs ~${Math.ceil(minimumW)}px at the ${participantTextFit.sublabelMinimum}px legible minimum, but participant "${participant.id}" provides ${availableTextW}px — shorten the sublabel (${participantBoxWidthNote}).`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const message of asArray(sequence.messages)) {
|
||||
if (!participants.has(message.from)) problems.push(`Message "${message.label}" references unknown source "${message.from}".`);
|
||||
if (!participants.has(message.to)) problems.push(`Message "${message.label}" references unknown target "${message.to}".`);
|
||||
if (typeof message.y !== 'number') problems.push(`Message "${message.label}" must provide a numeric y.`);
|
||||
if (message.y < layout.lifelineTop + 18 || message.y > layout.lifelineBottom - 18) {
|
||||
problems.push(`Message "${message.label}" sits outside the readable timeline — keep y between ${layout.lifelineTop + 18} and ${layout.lifelineBottom - 18}.`);
|
||||
}
|
||||
if (participants.has(message.from) && participants.has(message.to)) {
|
||||
const distance = Math.abs(participants.get(message.to).cx - participants.get(message.from).cx);
|
||||
if (distance < 60) problems.push(`Message "${message.label}" spans ${Math.round(distance)}px (minimum 60px) — give its participants more column distance.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Participant headers are opaque nodes. Lifelines, activation bars, and
|
||||
// segment bands remain intentional pass-through geometry and are excluded.
|
||||
problems.push(...cleanFlowProblems({
|
||||
relations: sequence.messages,
|
||||
obstacles: participants.values(),
|
||||
pathFor: messagePath,
|
||||
diagramType: 'sequence',
|
||||
relationCollection: 'messages',
|
||||
obstacleKind: 'participant header',
|
||||
clearance: 0,
|
||||
routeHint: 'move the message y below the participant headers or reorder participants'
|
||||
}));
|
||||
problems.push(...cleanCrossingProblems({
|
||||
relations: sequence.messages,
|
||||
endpointIds: new Set(participants.keys()),
|
||||
pathFor: messagePath,
|
||||
diagramType: 'sequence',
|
||||
relationCollection: 'messages',
|
||||
profile: sequence.meta?.quality_profile,
|
||||
routeHint: 'separate the message y values; lifeline crossings remain allowed'
|
||||
}));
|
||||
problems.push(...cleanAmbiguousCorridorProblems({
|
||||
relations: sequence.messages,
|
||||
endpointIds: new Set(participants.keys()),
|
||||
pathFor: messagePath,
|
||||
diagramType: 'sequence',
|
||||
relationCollection: 'messages',
|
||||
profile: sequence.meta?.quality_profile,
|
||||
routeHint: 'separate the message y values so unrelated messages do not visually merge'
|
||||
}));
|
||||
problems.push(...cleanBorderRunProblems({
|
||||
relations: sequence.messages,
|
||||
endpointIds: new Set(participants.keys()),
|
||||
frames: compositionFrames,
|
||||
pathFor: messagePath,
|
||||
diagramType: 'sequence',
|
||||
relationCollection: 'messages',
|
||||
profile: sequence.meta?.quality_profile,
|
||||
routeHint: 'move the message y so it crosses a segment boundary perpendicularly or stays clearly inside the segment'
|
||||
}));
|
||||
problems.push(...cleanRouteRhythmProblems({
|
||||
relations: sequence.messages,
|
||||
endpointIds: new Set(participants.keys()),
|
||||
pathFor: messagePath,
|
||||
diagramType: 'sequence',
|
||||
relationCollection: 'messages',
|
||||
profile: sequence.meta?.quality_profile,
|
||||
routeHint: 'increase participant spacing or simplify message routing so every turn has room to read'
|
||||
}));
|
||||
|
||||
// Vertical crowding only matters when the arrows share horizontal space;
|
||||
// disjoint arrows may legitimately run in parallel rows.
|
||||
const placed = asArray(sequence.messages)
|
||||
.filter((m) => participants.has(m.from) && participants.has(m.to))
|
||||
.map((m) => ({
|
||||
label: m.label,
|
||||
y: m.y,
|
||||
x1: Math.min(participants.get(m.from).cx, participants.get(m.to).cx),
|
||||
x2: Math.max(participants.get(m.from).cx, participants.get(m.to).cx)
|
||||
}))
|
||||
.sort((a, b) => a.y - b.y);
|
||||
for (let i = 0; i < placed.length; i += 1) {
|
||||
for (let j = i + 1; j < placed.length && placed[j].y - placed[i].y < 28; j += 1) {
|
||||
if (placed[i].x1 < placed[j].x2 && placed[j].x1 < placed[i].x2) {
|
||||
problems.push(`Messages "${placed[i].label}" and "${placed[j].label}" are less than 28px apart and share horizontal space — spread their y values.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Label masks can extend well past the arrow span, so check the actual
|
||||
// label rectangles too — tangent arrows with long labels still collide.
|
||||
const labelRects = asArray(sequence.messages)
|
||||
.map((m, messageIndex) => messageLabelBox(m, messageIndex))
|
||||
.filter(Boolean);
|
||||
for (let i = 0; i < labelRects.length; i += 1) {
|
||||
for (let j = i + 1; j < labelRects.length; j += 1) {
|
||||
if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
|
||||
problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — spread their message y values or shorten the labels.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
problems.push(...cleanLabelRouteClearanceProblems({
|
||||
relations: sequence.messages,
|
||||
labels: labelRects,
|
||||
endpointIds: new Set(participants.keys()),
|
||||
pathFor: messagePath,
|
||||
diagramType: 'sequence',
|
||||
relationCollection: 'messages',
|
||||
profile: sequence.meta?.quality_profile,
|
||||
routeHint: 'spread the message y values, shorten the label, or reorder participants so the adjacent route stays visible'
|
||||
}));
|
||||
|
||||
for (const segment of asArray(sequence.segments)) {
|
||||
if (segment.to <= segment.from) {
|
||||
problems.push(`Segment "${segment.label}" has invalid y range (from ${segment.from} to ${segment.to}) — "to" must be greater than "from".`);
|
||||
}
|
||||
if (segment.from < layout.topY || segment.to > layout.lifelineBottom + 20) {
|
||||
problems.push(`Segment "${segment.label}" extends outside the canvas — keep its y range between ${layout.topY} and ${layout.lifelineBottom + 20}.`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const activation of asArray(sequence.activations)) {
|
||||
if (!participants.has(activation.participant)) problems.push(`Activation references unknown participant "${activation.participant}".`);
|
||||
if (activation.to <= activation.from) problems.push(`Activation for "${activation.participant}" has invalid time range — "to" must be greater than "from".`);
|
||||
}
|
||||
|
||||
const lastParticipant = asArray(sequence.participants)[asArray(sequence.participants).length - 1];
|
||||
if (lastParticipant && participants.get(lastParticipant.id).cx + layout.participantW / 2 > viewBox[0] - 40) {
|
||||
const requiredWidth = Math.ceil(participants.get(lastParticipant.id).cx + layout.participantW / 2 + 40);
|
||||
problems.push(`Participants exceed viewBox width — set meta.viewBox[0] to at least ${requiredWidth} or remove a participant.`);
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
throwDiagnosticProblems('Sequence layout validation failed', problems, {
|
||||
subject: { diagramType: 'sequence' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderParticipant(participant) {
|
||||
const fill = componentFill[participant.type] || 'c-external';
|
||||
const hasSub = participant.sublabel != null && participant.sublabel !== '';
|
||||
const sub = hasSub
|
||||
? `\n <text data-detail="context" x="${participant.cx}" y="${layout.topY + 39}" class="t-muted" font-size="${fittedNodeFontSize(participant.sublabel, layout.participantW, participantTextFit.sublabelPreferred, participantTextFit.sublabelMinimum)}" text-anchor="middle">${esc(participant.sublabel)}</text>`
|
||||
: '';
|
||||
const brand = renderBrandMark(participant, { x: participant.x + layout.participantW - 22, y: layout.topY + 6 });
|
||||
const labelFontSize = fittedNodeFontSize(participant.label, brandLabelFitWidth(participant, layout.participantW), 11, 8);
|
||||
const passport = {
|
||||
kind: participant.type,
|
||||
sublabel: participant.sublabel,
|
||||
context: i18nText(sequence.meta.locale, 'node.context.sequence'),
|
||||
...brandMetadataFor(participant),
|
||||
};
|
||||
return ` <g ${focusNodeAttrs(participant.id, participant.label, passport, sequence.meta.locale)}>
|
||||
${focusNodeTitle(participant.label, passport)}
|
||||
<rect x="${participant.x}" y="${layout.topY}" width="${layout.participantW}" height="${layout.participantH}" rx="6" class="c-mask"/>
|
||||
<rect x="${participant.x}" y="${layout.topY}" width="${layout.participantW}" height="${layout.participantH}" rx="6" class="${fill}"${animateAttr(sequence.meta, 'node', participant.index)} stroke-width="1.5"/>
|
||||
${renderSemanticSigil(participant.type, { x: participant.x + 6, y: layout.topY + 6 })}${brand ? `\n ${brand}` : ''}
|
||||
<text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${participant.cx}" y="${layout.topY + 22}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(participant.label)}</text>${sub}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
function renderLifeline(participant) {
|
||||
return ` <path d="M ${participant.cx} ${layout.lifelineTop} L ${participant.cx} ${layout.lifelineBottom}" class="a-default" stroke-width="0.8" stroke-dasharray="3,7"/>`;
|
||||
}
|
||||
|
||||
function renderSegment(segment, index) {
|
||||
return ` <rect data-graph-role="structural-frame" data-composition-frame-kind="segment" data-composition-frame-id="${index}" x="48" y="${segment.from}" width="${viewBox[0] - 96}" height="${segment.to - segment.from}" rx="10" class="c-lane" stroke-width="1"/>`;
|
||||
}
|
||||
|
||||
function renderSegmentLabel(segment, index) {
|
||||
const labelW = Math.max(42, textUnits(segment.label) * 5.2 + 14);
|
||||
const occupied = asArray(sequence.messages)
|
||||
.flatMap((message) => [messageLabelBox(message), messageRouteBox(message)])
|
||||
.filter(Boolean);
|
||||
const label = { x: 56, y: segment.from - 22, width: labelW, height: 18 };
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
if (!occupied.some((rect) => rectsOverlap(label, rect, 2))) break;
|
||||
label.y -= 22;
|
||||
}
|
||||
return ` <g data-graph-role="segment-label" data-segment-id="${index}">
|
||||
<rect x="${label.x}" y="${label.y}" width="${label.width}" height="${label.height}" rx="3" class="c-mask"/>
|
||||
<text x="${label.x + 6}" y="${label.y + 13}" class="t-dim" font-size="9" font-weight="600">${esc(segment.label)}</text>
|
||||
</g>`;
|
||||
}
|
||||
|
||||
function renderActivation(activation) {
|
||||
const participant = participants.get(activation.participant);
|
||||
const fill = componentFill[activation.type] || componentFill[participant.type] || 'c-external';
|
||||
const x = participant.cx - 5;
|
||||
const height = activation.to - activation.from;
|
||||
return ` <rect x="${x}" y="${activation.from}" width="10" height="${height}" rx="3" class="c-mask"/>
|
||||
<rect x="${x}" y="${activation.from}" width="10" height="${height}" rx="3" class="${fill}" stroke-width="1"/>`;
|
||||
}
|
||||
|
||||
function messageLabel(message, x1, x2) {
|
||||
const box = messageLabelBox(message);
|
||||
const center = box ? box.x + box.width / 2 : (x1 + x2) / 2;
|
||||
const y = message.y - 10;
|
||||
const labelW = box?.width || Math.max(34, textUnits(message.label) * 5.2 + 12);
|
||||
const accent = message.variant === 'security'
|
||||
? 't-security'
|
||||
: message.variant === 'dashed'
|
||||
? 't-messagebus'
|
||||
: message.variant === 'return'
|
||||
? 't-muted'
|
||||
: 't-backend';
|
||||
return ` <g data-detail="context">
|
||||
<rect x="${center - labelW / 2}" y="${y - 10}" width="${labelW}" height="${layout.labelH}" rx="3" class="c-mask"/>
|
||||
<text x="${center}" y="${y}" class="${accent}" font-size="9" text-anchor="middle">${esc(message.label)}</text>
|
||||
</g>`;
|
||||
}
|
||||
|
||||
function renderMessage(message, index) {
|
||||
const { start, end } = messageGeometry(message);
|
||||
const [cls, marker] = arrowClass[message.variant || 'default'] || arrowClass.default;
|
||||
const strokeWidth = message.variant === 'emphasis' ? 1.8 : 1.4;
|
||||
const dash = message.variant === 'return' ? ' stroke-dasharray="3,5"' : '';
|
||||
const note = message.note
|
||||
? `\n <text data-detail="fine" x="${Math.min(start, end) + 12}" y="${message.y + 18}" class="t-dim" font-size="7">${esc(message.note)}</text>`
|
||||
: '';
|
||||
return ` <g ${focusEdgeAttrs(message.from, message.to, message.label, index, message.id)}>
|
||||
<path data-composition-edge-from="${esc(message.from)}" data-composition-edge-to="${esc(message.to)}"${message.id ? ` data-composition-edge-id="${esc(message.id)}"` : ''} data-composition-points="${routePointsValue([[start, message.y], [end, message.y]])}" d="M ${start} ${message.y} L ${end} ${message.y}" class="${cls}"${animateAttr(sequence.meta, 'edge', index)} stroke-width="${strokeWidth}"${dash} marker-end="url(#${marker})"/>
|
||||
${messageLabel(message, start, end)}${note}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
const LEGEND_CATALOG = [
|
||||
{ kind: 'emphasis', className: 'a-emphasis', marker: 'arrowhead-emphasis', strokeWidth: 1.8 },
|
||||
{ kind: 'return', className: 'a-default', marker: 'arrowhead', dash: '3,5' },
|
||||
{ kind: 'security', className: 'a-security', marker: 'arrowhead-security' },
|
||||
{ kind: 'dashed', className: 'a-dashed', marker: 'arrowhead-dashed' },
|
||||
{ kind: 'default', className: 'a-default', marker: 'arrowhead' },
|
||||
].map((entry) => ({
|
||||
...entry,
|
||||
interactive: false,
|
||||
swatchWidth: 34,
|
||||
swatchGap: 9,
|
||||
label: i18nText(sequence.meta.locale, `legend.sequence.${entry.kind}`),
|
||||
}));
|
||||
|
||||
function renderLegend() {
|
||||
const presentKinds = new Set(asArray(sequence.messages).map((message) => message.variant || 'default'));
|
||||
const entries = resolveLegend(sequence.meta?.legend, LEGEND_CATALOG, presentKinds);
|
||||
return renderResolvedLegend({
|
||||
entries,
|
||||
locale: sequence.meta.locale,
|
||||
layout: {
|
||||
x: 40,
|
||||
baselineY: layout.legendY,
|
||||
width: viewBox[0] - 80,
|
||||
minTitleY: layout.legendY - 30,
|
||||
unfit: sequence.meta?.legend === undefined ? 'hide' : 'error',
|
||||
diagramType: 'sequence',
|
||||
},
|
||||
renderSwatch: (entry) => `<path d="M ${entry.x} ${entry.baseline - 3} L ${entry.x + 34} ${entry.baseline - 3}" class="${entry.className}" stroke-width="${entry.strokeWidth || 1.4}"${entry.dash ? ` stroke-dasharray="${entry.dash}"` : ''} marker-end="url(#${entry.marker})"/>`,
|
||||
});
|
||||
}
|
||||
|
||||
function renderSvg() {
|
||||
const participantList = [...participants.values()];
|
||||
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(sequence.meta)}>
|
||||
${svgAccessibleText(sequence.meta, 'sequence')}
|
||||
${renderDefinitions()}
|
||||
|
||||
<!-- Background Grid -->
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
|
||||
<!-- Time Segments -->
|
||||
${asArray(sequence.segments).map(renderSegment).join('\n\n')}
|
||||
|
||||
<!-- Lifelines -->
|
||||
${participantList.map(renderLifeline).join('\n')}
|
||||
|
||||
<!-- Activations -->
|
||||
${asArray(sequence.activations).map(renderActivation).join('\n')}
|
||||
|
||||
<!-- Messages -->
|
||||
${asArray(sequence.messages).map(renderMessage).join('\n\n')}
|
||||
|
||||
<!-- Segment Labels -->
|
||||
${asArray(sequence.segments).map(renderSegmentLabel).join('\n')}
|
||||
|
||||
<!-- Participants -->
|
||||
${participantList.map(renderParticipant).join('\n\n')}
|
||||
|
||||
<!-- Legend -->
|
||||
${renderLegend()}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
validateSequence();
|
||||
writeDiagram({
|
||||
outPath,
|
||||
template,
|
||||
diagramType: 'sequence',
|
||||
meta: sequence.meta,
|
||||
svg: renderSvg(),
|
||||
cards: sequence.cards,
|
||||
});
|
||||
@@ -0,0 +1,563 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import net from 'node:net';
|
||||
import { BRAND_MARKS } from './generated-brand-marks.mjs';
|
||||
import { throwDiagnosticError } from './diagnostics.mjs';
|
||||
import { esc, textUnits } from './utils.mjs';
|
||||
|
||||
const COLLECTIONS = Object.freeze({
|
||||
architecture: 'components',
|
||||
workflow: 'nodes',
|
||||
sequence: 'participants',
|
||||
dataflow: 'nodes',
|
||||
lifecycle: 'states',
|
||||
});
|
||||
const MARK_BY_LOOKUP = new Map();
|
||||
const MARK_BY_DOMAIN = new Map();
|
||||
const RESOLVED_BY_NODE = new WeakMap();
|
||||
const RESOLVED_MARK = Symbol('archify.brandMark');
|
||||
const MAX_HTML_BYTES = 256 * 1024;
|
||||
const MAX_IMAGE_BYTES = 1024 * 1024;
|
||||
const MAX_CAPTURE_CONCURRENCY = 3;
|
||||
const DEFAULT_CAPTURE_TIMEOUT_MS = 8000;
|
||||
const USER_AGENT = 'Archify/2.15 brand-preview';
|
||||
|
||||
function lookupForms(value) {
|
||||
const raw = String(value ?? '').trim().toLocaleLowerCase('en-US');
|
||||
if (!raw) return [];
|
||||
const dashed = raw.replace(/[\s_]+/g, '-');
|
||||
const compact = raw.replace(/[\s_.-]+/g, '');
|
||||
return [...new Set([raw, dashed, compact])];
|
||||
}
|
||||
|
||||
for (const mark of BRAND_MARKS) {
|
||||
for (const value of [mark.id, mark.title, ...mark.aliases]) {
|
||||
for (const form of lookupForms(value)) {
|
||||
if (!MARK_BY_LOOKUP.has(form)) MARK_BY_LOOKUP.set(form, mark);
|
||||
}
|
||||
}
|
||||
for (const domain of mark.domains) MARK_BY_DOMAIN.set(domain, mark);
|
||||
}
|
||||
|
||||
function asUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value));
|
||||
return ['https:', 'http:'].includes(url.protocol) ? url : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function domainMark(hostname) {
|
||||
const host = hostname.toLocaleLowerCase('en-US').replace(/\.$/, '');
|
||||
const candidates = [...MARK_BY_DOMAIN.entries()]
|
||||
.filter(([domain]) => host === domain || host.endsWith(`.${domain}`))
|
||||
.sort(([left], [right]) => right.length - left.length);
|
||||
return candidates[0]?.[1] || null;
|
||||
}
|
||||
|
||||
export function findBrandMark(value) {
|
||||
const url = asUrl(value);
|
||||
if (url) return domainMark(url.hostname);
|
||||
for (const form of lookupForms(value)) {
|
||||
const mark = MARK_BY_LOOKUP.get(form);
|
||||
if (mark) return mark;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function listBrandMarks(query = '') {
|
||||
const needle = String(query).trim().toLocaleLowerCase('en-US');
|
||||
return BRAND_MARKS.filter((mark) => {
|
||||
if (!needle) return true;
|
||||
return [mark.id, mark.title, mark.category, ...mark.aliases, ...mark.domains]
|
||||
.some((value) => String(value).toLocaleLowerCase('en-US').includes(needle));
|
||||
}).map(({ path, ...mark }) => mark);
|
||||
}
|
||||
|
||||
function ipv4Private(address) {
|
||||
const parts = address.split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true;
|
||||
const [a, b, c] = parts;
|
||||
return a === 0 || a === 10 || a === 127 || a >= 224
|
||||
|| (a === 100 && b >= 64 && b <= 127)
|
||||
|| (a === 169 && b === 254)
|
||||
|| (a === 172 && b >= 16 && b <= 31)
|
||||
|| (a === 192 && b === 0 && (c === 0 || c === 2))
|
||||
|| (a === 192 && b === 88 && c === 99)
|
||||
|| (a === 192 && b === 168)
|
||||
|| (a === 198 && (b === 18 || b === 19))
|
||||
|| (a === 198 && b === 51 && c === 100)
|
||||
|| (a === 203 && b === 0 && c === 113);
|
||||
}
|
||||
|
||||
function ipv6Private(address) {
|
||||
const normalized = address.toLocaleLowerCase('en-US').split('%')[0];
|
||||
if (normalized === '::' || normalized === '::1') return true;
|
||||
if (normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('ff') || /^fe[89ab]/.test(normalized)) return true;
|
||||
if (normalized.startsWith('64:ff9b:') || normalized.startsWith('100:')
|
||||
|| normalized.startsWith('2001:db8:') || normalized.startsWith('2002:')) return true;
|
||||
const mappedDotted = normalized.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (mappedDotted) return ipv4Private(mappedDotted[1]);
|
||||
const mappedHex = normalized.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
||||
if (mappedHex) {
|
||||
const high = Number.parseInt(mappedHex[1], 16);
|
||||
const low = Number.parseInt(mappedHex[2], 16);
|
||||
return ipv4Private(`${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}`);
|
||||
}
|
||||
const compatibleHex = normalized.match(/^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
||||
if (compatibleHex) {
|
||||
const high = Number.parseInt(compatibleHex[1], 16);
|
||||
const low = Number.parseInt(compatibleHex[2], 16);
|
||||
return ipv4Private(`${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isPrivateBrandAddress(address) {
|
||||
const family = net.isIP(address);
|
||||
return family === 4 ? ipv4Private(address) : (family === 6 ? ipv6Private(address) : true);
|
||||
}
|
||||
|
||||
function validateUrlShape(url, allowPrivate = process.env.ARCHIFY_BRAND_ALLOW_PRIVATE === '1') {
|
||||
if (!['https:', 'http:'].includes(url.protocol)) throw new Error('only HTTP(S) brand links are supported');
|
||||
if (url.username || url.password) throw new Error('brand links cannot contain credentials');
|
||||
const expectedPort = url.protocol === 'https:' ? '443' : '80';
|
||||
if (!allowPrivate && url.port && url.port !== expectedPort) {
|
||||
throw new Error('brand links must use a standard web port');
|
||||
}
|
||||
const host = url.hostname.toLocaleLowerCase('en-US').replace(/\.$/, '').replace(/^\[|\]$/g, '');
|
||||
if (!allowPrivate && (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local'))) {
|
||||
throw new Error('private brand links are not fetched');
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
function beforeDeadline(promise, deadline) {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) return Promise.reject(new Error('brand capture timed out'));
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('brand capture timed out')), remaining);
|
||||
timer.unref?.();
|
||||
promise.then(
|
||||
(value) => { clearTimeout(timer); resolve(value); },
|
||||
(error) => { clearTimeout(timer); reject(error); },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveRequestTarget(url, deadline) {
|
||||
const allowPrivate = process.env.ARCHIFY_BRAND_ALLOW_PRIVATE === '1';
|
||||
const host = validateUrlShape(url, allowPrivate);
|
||||
const directFamily = net.isIP(host);
|
||||
const addresses = directFamily
|
||||
? [{ address: host, family: directFamily }]
|
||||
: await beforeDeadline(lookup(host, { all: true, verbatim: true }), deadline);
|
||||
if (!addresses.length || (!allowPrivate && addresses.some(({ address }) => isPrivateBrandAddress(address)))) {
|
||||
throw new Error('private brand links are not fetched');
|
||||
}
|
||||
return addresses[0];
|
||||
}
|
||||
|
||||
function timeoutSignal(milliseconds) {
|
||||
if (typeof AbortSignal.timeout === 'function') return AbortSignal.timeout(milliseconds);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), milliseconds);
|
||||
timer.unref?.();
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
function captureTimeoutMilliseconds() {
|
||||
const configured = Number(process.env.ARCHIFY_BRAND_CAPTURE_TIMEOUT_MS);
|
||||
if (!Number.isFinite(configured)) return DEFAULT_CAPTURE_TIMEOUT_MS;
|
||||
return Math.max(100, Math.min(30000, Math.round(configured)));
|
||||
}
|
||||
|
||||
function requestPinned(url, accept, target, deadline) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
const request = transport.request(url, {
|
||||
method: 'GET',
|
||||
signal: timeoutSignal(Math.max(1, Math.min(4500, deadline - Date.now()))),
|
||||
headers: { accept, 'user-agent': USER_AGENT },
|
||||
// Reuse the exact public address that passed validation. This closes the
|
||||
// DNS-rebinding gap between checking a hostname and opening its socket.
|
||||
lookup(_hostname, options, callback) {
|
||||
if (options?.all) callback(null, [target]);
|
||||
else callback(null, target.address, target.family);
|
||||
},
|
||||
}, (response) => {
|
||||
const status = response.statusCode || 0;
|
||||
resolve({
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
headers: {
|
||||
get(name) {
|
||||
const value = response.headers[String(name).toLocaleLowerCase('en-US')];
|
||||
return Array.isArray(value) ? value.join(', ') : (value ?? null);
|
||||
},
|
||||
},
|
||||
body: response,
|
||||
});
|
||||
});
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function checkedFetch(input, accept, deadline) {
|
||||
let current = new URL(input);
|
||||
for (let redirects = 0; redirects <= 3; redirects += 1) {
|
||||
if (Date.now() >= deadline) throw new Error('brand capture timed out');
|
||||
const target = await resolveRequestTarget(current, deadline);
|
||||
const response = await requestPinned(current, accept, target, deadline);
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
const location = response.headers.get('location');
|
||||
response.body.resume();
|
||||
if (!location || redirects === 3) throw new Error('brand link redirected too many times');
|
||||
current = new URL(location, current);
|
||||
continue;
|
||||
}
|
||||
if (!response.ok) {
|
||||
response.body.resume();
|
||||
throw new Error(`brand link returned HTTP ${response.status}`);
|
||||
}
|
||||
return { response, finalUrl: current };
|
||||
}
|
||||
throw new Error('brand link redirected too many times');
|
||||
}
|
||||
|
||||
async function readLimited(response, maximum) {
|
||||
const declared = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(declared) && declared > maximum) {
|
||||
response.body?.destroy?.();
|
||||
throw new Error('brand asset is too large');
|
||||
}
|
||||
if (response.body && typeof response.body[Symbol.asyncIterator] === 'function') {
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
for await (const value of response.body) {
|
||||
total += value.byteLength;
|
||||
if (total > maximum) {
|
||||
response.body.destroy?.();
|
||||
throw new Error('brand asset is too large');
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks, total);
|
||||
}
|
||||
if (!response.body?.getReader) {
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.length > maximum) throw new Error('brand asset is too large');
|
||||
return buffer;
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maximum) {
|
||||
await reader.cancel();
|
||||
throw new Error('brand asset is too large');
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks, total);
|
||||
}
|
||||
|
||||
function attribute(tag, name) {
|
||||
const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'));
|
||||
return match ? (match[1] ?? match[2] ?? match[3] ?? '') : '';
|
||||
}
|
||||
|
||||
function iconCandidates(html, pageUrl) {
|
||||
const candidates = [];
|
||||
for (const match of html.matchAll(/<link\b[^>]*>/gi)) {
|
||||
const tag = match[0];
|
||||
const rel = attribute(tag, 'rel').toLocaleLowerCase('en-US').split(/\s+/);
|
||||
if (!rel.some((value) => value === 'icon' || value === 'apple-touch-icon' || value === 'mask-icon')) continue;
|
||||
const href = attribute(tag, 'href');
|
||||
if (!href) continue;
|
||||
try {
|
||||
const url = new URL(href, pageUrl);
|
||||
if (!['https:', 'http:'].includes(url.protocol)) continue;
|
||||
const type = attribute(tag, 'type').toLocaleLowerCase('en-US');
|
||||
const sizes = attribute(tag, 'sizes');
|
||||
const area = [...sizes.matchAll(/(\d+)x(\d+)/gi)]
|
||||
.reduce((best, size) => Math.max(best, Number(size[1]) * Number(size[2])), 0);
|
||||
const score = (type.includes('svg') || /\.svg(?:$|[?#])/i.test(url.href) ? 1000000 : 0)
|
||||
+ (rel.includes('apple-touch-icon') ? 500000 : 0)
|
||||
+ area;
|
||||
candidates.push({ url, score });
|
||||
} catch {
|
||||
// A malformed icon candidate is ignored; the deterministic fallback remains available.
|
||||
}
|
||||
}
|
||||
candidates.sort((left, right) => right.score - left.score);
|
||||
const fallback = new URL('/favicon.ico', pageUrl);
|
||||
const unique = new Map(candidates.map((candidate) => [candidate.url.href, candidate]));
|
||||
unique.delete(fallback.href);
|
||||
return [...unique.values()].slice(0, 5).concat({ url: fallback, score: -1 });
|
||||
}
|
||||
|
||||
async function imageData(response) {
|
||||
const contentType = (response.headers.get('content-type') || '').split(';')[0].trim().toLocaleLowerCase('en-US');
|
||||
const allowed = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/x-icon',
|
||||
'image/vnd.microsoft.icon',
|
||||
]);
|
||||
if (!allowed.has(contentType)) {
|
||||
response.body?.destroy?.();
|
||||
throw new Error(`unsupported brand image type ${contentType || 'unknown'}`);
|
||||
}
|
||||
const buffer = await readLimited(response, MAX_IMAGE_BYTES);
|
||||
const signatureMatches = contentType === 'image/png'
|
||||
? buffer.length >= 45
|
||||
&& buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||
&& buffer.readUInt32BE(8) === 13
|
||||
&& buffer.toString('ascii', 12, 16) === 'IHDR'
|
||||
&& buffer.readUInt32BE(16) > 0
|
||||
&& buffer.readUInt32BE(20) > 0
|
||||
&& buffer.toString('ascii', buffer.length - 8, buffer.length - 4) === 'IEND'
|
||||
: (contentType === 'image/jpeg'
|
||||
? buffer.length >= 20
|
||||
&& buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff
|
||||
&& buffer.at(-2) === 0xff && buffer.at(-1) === 0xd9
|
||||
: (contentType === 'image/webp'
|
||||
? buffer.length >= 16
|
||||
&& buffer.toString('ascii', 0, 4) === 'RIFF'
|
||||
&& buffer.toString('ascii', 8, 12) === 'WEBP'
|
||||
&& buffer.readUInt32LE(4) + 8 <= buffer.length
|
||||
: buffer.length >= 22
|
||||
&& buffer[0] === 0 && buffer[1] === 0 && buffer[2] === 1 && buffer[3] === 0
|
||||
&& buffer.readUInt16LE(4) > 0
|
||||
&& 6 + buffer.readUInt16LE(4) * 16 <= buffer.length));
|
||||
if (!signatureMatches) throw new Error(`brand asset bytes do not match ${contentType}`);
|
||||
return {
|
||||
dataUrl: `data:${contentType};base64,${buffer.toString('base64')}`,
|
||||
sha256: createHash('sha256').update(buffer).digest('hex'),
|
||||
contentType,
|
||||
};
|
||||
}
|
||||
|
||||
async function captureRemoteBrand(value, deadline = Date.now() + captureTimeoutMilliseconds()) {
|
||||
const sourceUrl = new URL(value);
|
||||
const fallback = (reason) => ({
|
||||
id: sourceUrl.hostname,
|
||||
title: sourceUrl.hostname,
|
||||
category: 'link',
|
||||
kind: 'fallback',
|
||||
status: 'unavailable',
|
||||
sourceUrl: sourceUrl.href,
|
||||
reason,
|
||||
});
|
||||
try {
|
||||
const page = await checkedFetch(sourceUrl, 'text/html,application/xhtml+xml,image/*;q=0.8', deadline);
|
||||
const pageType = (page.response.headers.get('content-type') || '').toLocaleLowerCase('en-US');
|
||||
if (pageType.startsWith('image/')) {
|
||||
const image = await imageData(page.response);
|
||||
return {
|
||||
id: sourceUrl.hostname,
|
||||
title: sourceUrl.hostname,
|
||||
category: 'link',
|
||||
kind: 'remote',
|
||||
status: 'captured',
|
||||
sourceUrl: sourceUrl.href,
|
||||
resolvedUrl: page.finalUrl.href,
|
||||
...image,
|
||||
};
|
||||
}
|
||||
if (!pageType.includes('text/html') && !pageType.includes('application/xhtml+xml')) {
|
||||
page.response.body?.destroy?.();
|
||||
return fallback('linked page is not HTML');
|
||||
}
|
||||
const html = (await readLimited(page.response, MAX_HTML_BYTES)).toString('utf8');
|
||||
const iconErrors = [];
|
||||
for (const candidate of iconCandidates(html, page.finalUrl)) {
|
||||
try {
|
||||
const fetched = await checkedFetch(candidate.url, 'image/*', deadline);
|
||||
const image = await imageData(fetched.response);
|
||||
return {
|
||||
id: sourceUrl.hostname,
|
||||
title: sourceUrl.hostname,
|
||||
category: 'link',
|
||||
kind: 'remote',
|
||||
status: 'captured',
|
||||
sourceUrl: sourceUrl.href,
|
||||
resolvedUrl: fetched.finalUrl.href,
|
||||
...image,
|
||||
};
|
||||
} catch (error) {
|
||||
iconErrors.push(error);
|
||||
// Try the next declared favicon before using the generic link mark.
|
||||
}
|
||||
}
|
||||
const usefulError = iconErrors.find((error) => /unsupported brand image type/i.test(error?.message))
|
||||
|| iconErrors.at(-1);
|
||||
return fallback(usefulError?.message || 'no usable site icon was found');
|
||||
} catch (error) {
|
||||
return fallback(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function captureBrandReference(value) {
|
||||
const url = asUrl(value);
|
||||
if (!url) throw new Error('brand capture requires one HTTP(S) URL');
|
||||
validateUrlShape(url);
|
||||
const preset = findBrandMark(url.href);
|
||||
if (preset) return { brand: preset.id, resolved: { ...preset, kind: 'preset', status: 'preset' } };
|
||||
const resolved = await captureRemoteBrand(url.href);
|
||||
if (resolved.status !== 'captured' || !resolved.sha256) {
|
||||
throw new Error(`brand capture failed: ${resolved.reason || 'no usable site icon was found'}`);
|
||||
}
|
||||
return {
|
||||
brand: { url: url.href, sha256: resolved.sha256 },
|
||||
resolved,
|
||||
};
|
||||
}
|
||||
|
||||
function remoteBrand(value, cache, deadline) {
|
||||
const key = new URL(value).href;
|
||||
if (!cache.has(key)) cache.set(key, captureRemoteBrand(key, deadline));
|
||||
return cache.get(key);
|
||||
}
|
||||
|
||||
function suggestions(value) {
|
||||
const needle = lookupForms(value)[0] || '';
|
||||
return BRAND_MARKS.map((mark) => ({
|
||||
id: mark.id,
|
||||
score: lookupForms(mark.id).some((form) => form.includes(needle) || needle.includes(form)) ? 0 : 1,
|
||||
})).sort((left, right) => left.score - right.score || left.id.localeCompare(right.id))
|
||||
.slice(0, 5)
|
||||
.map((entry) => entry.id);
|
||||
}
|
||||
|
||||
async function mapConcurrent(values, limit, visit) {
|
||||
let cursor = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, values.length) }, async () => {
|
||||
while (cursor < values.length) {
|
||||
const index = cursor;
|
||||
cursor += 1;
|
||||
await visit(values[index], index);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
export async function prepareDiagramBrandMarks(diagramType, diagram) {
|
||||
const collection = COLLECTIONS[diagramType];
|
||||
const nodes = collection && Array.isArray(diagram[collection]) ? diagram[collection] : [];
|
||||
const unknown = [];
|
||||
const remoteByUrl = new Map();
|
||||
const deadline = Date.now() + captureTimeoutMilliseconds();
|
||||
await mapConcurrent(nodes, MAX_CAPTURE_CONCURRENCY, async (node, index) => {
|
||||
if (!node.brand) return;
|
||||
if (typeof node.brand === 'object') {
|
||||
const url = asUrl(node.brand.url);
|
||||
const resolved = url ? await remoteBrand(url.href, remoteByUrl, deadline) : null;
|
||||
if (!resolved || resolved.status !== 'captured') {
|
||||
unknown.push(`/${collection}/${index}/brand could not reproduce the pinned capture: ${resolved?.reason || 'invalid URL'}`);
|
||||
return;
|
||||
}
|
||||
if (resolved.sha256 !== node.brand.sha256) {
|
||||
unknown.push(`/${collection}/${index}/brand digest changed: expected ${node.brand.sha256}, received ${resolved.sha256}`);
|
||||
return;
|
||||
}
|
||||
node[RESOLVED_MARK] = resolved;
|
||||
RESOLVED_BY_NODE.set(node, resolved);
|
||||
return;
|
||||
}
|
||||
const preset = findBrandMark(node.brand);
|
||||
if (preset) {
|
||||
const resolved = { ...preset, kind: 'preset', status: 'preset', sourceUrl: preset.provenance.source };
|
||||
node[RESOLVED_MARK] = resolved;
|
||||
RESOLVED_BY_NODE.set(node, resolved);
|
||||
return;
|
||||
}
|
||||
const url = asUrl(node.brand);
|
||||
if (url) {
|
||||
unknown.push(`/${collection}/${index}/brand ${JSON.stringify(node.brand)} is an unpinned URL; capture it first with \`archify brands capture ${url.href} --json\``);
|
||||
return;
|
||||
}
|
||||
unknown.push(`/${collection}/${index}/brand ${JSON.stringify(node.brand)} is not a built-in brand; closest IDs: ${suggestions(node.brand).join(', ')}`);
|
||||
});
|
||||
if (unknown.length) {
|
||||
throwDiagnosticError(`Brand mark validation failed:\n- ${unknown.join('\n- ')}`, unknown.map((message) => ({
|
||||
code: message.includes('is an unpinned URL') ? 'brand/unpinned-url'
|
||||
: (message.includes('digest changed') ? 'brand/digest-mismatch'
|
||||
: (message.includes('could not reproduce') ? 'brand/capture-unavailable' : 'brand/unknown')),
|
||||
severity: 'error',
|
||||
message,
|
||||
subject: { diagramType, collection },
|
||||
evidence: {},
|
||||
supportedFixes: message.includes('is an unpinned URL')
|
||||
? ['run `archify brands capture <url> --json` and author the returned digest-pinned brand object']
|
||||
: ['choose an ID from `archify brands`', 'run `archify brands capture <url> --json` for an unknown official site'],
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
export function brandMarkFor(node) {
|
||||
return node?.[RESOLVED_MARK] || RESOLVED_BY_NODE.get(node) || null;
|
||||
}
|
||||
|
||||
export function brandMetadataFor(node) {
|
||||
const mark = brandMarkFor(node);
|
||||
return mark ? {
|
||||
brand: mark.title,
|
||||
brandId: mark.id,
|
||||
brandStatus: mark.status,
|
||||
brandSource: mark.sourceUrl,
|
||||
} : {};
|
||||
}
|
||||
|
||||
export function brandLabelFitWidth(node, width) {
|
||||
return brandMarkFor(node) ? Math.max(1, width - 48) : width;
|
||||
}
|
||||
|
||||
export function brandTopRailProblem(node, width, minimumFontSize, subject = 'Node') {
|
||||
if (!brandMarkFor(node)) return null;
|
||||
const available = width - 48;
|
||||
const required = textUnits(node.label) * minimumFontSize * 0.6;
|
||||
if (available >= required) return null;
|
||||
return `${subject} "${node.id}" brand top rail leaves ${Math.max(0, available)}px for its label, but `
|
||||
+ `"${node.label}" needs ~${Math.ceil(required)}px at the ${minimumFontSize}px legible minimum — widen the node or shorten the label.`;
|
||||
}
|
||||
|
||||
function markAttrs(mark) {
|
||||
return [
|
||||
`data-brand-mark="${esc(mark.id)}"`,
|
||||
`data-brand-title="${esc(mark.title)}"`,
|
||||
`data-brand-status="${esc(mark.status)}"`,
|
||||
mark.sourceUrl ? `data-brand-source="${esc(mark.sourceUrl)}"` : '',
|
||||
mark.sha256 ? `data-brand-sha256="${esc(mark.sha256)}"` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export function renderBrandMark(node, { x, y, size = 16 } = {}) {
|
||||
const mark = brandMarkFor(node);
|
||||
if (!mark) return '';
|
||||
const inset = 3;
|
||||
let content;
|
||||
if (mark.kind === 'preset') {
|
||||
const scale = (size - inset * 2) / mark.viewBox;
|
||||
content = `<path d="${esc(mark.path)}" transform="translate(${inset} ${inset}) scale(${scale})" fill="#${esc(mark.hex)}"/>`;
|
||||
} else if (mark.kind === 'remote') {
|
||||
content = `<image href="${esc(mark.dataUrl)}" x="${inset}" y="${inset}" width="${size - inset * 2}" height="${size - inset * 2}" preserveAspectRatio="xMidYMid meet"/>`;
|
||||
} else {
|
||||
const scale = size / 20;
|
||||
content = `<g transform="scale(${scale})" class="brand-mark-fallback"><circle cx="10" cy="10" r="5.2"/><path d="M4.8 10h10.4M10 4.8c1.6 1.6 2.4 3.3 2.4 5.2s-.8 3.6-2.4 5.2M10 4.8C8.4 6.4 7.6 8.1 7.6 10s.8 3.6 2.4 5.2"/></g>`;
|
||||
}
|
||||
return `<g aria-hidden="true" ${markAttrs(mark)} class="brand-mark" transform="translate(${x} ${y})">
|
||||
<rect width="${size}" height="${size}" rx="4" class="brand-mark-badge"/>
|
||||
${content}
|
||||
<rect width="${size}" height="${size}" rx="4" class="brand-mark-frame"/>
|
||||
</g>`;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { applyTemplate, renderCards, esc } from './utils.mjs';
|
||||
import { validateSchema } from './validator.mjs';
|
||||
import { verifyRepositoryEvidence } from './repository-evidence.mjs';
|
||||
import { installRendererDiagnosticBoundary, throwDiagnosticProblems } from './diagnostics.mjs';
|
||||
import { validateEngineeringProfile } from './engineering-profiles.mjs';
|
||||
import { resolveOutputPath } from './output-path.mjs';
|
||||
import { prepareDiagramBrandMarks } from './brand-marks.mjs';
|
||||
import { resolveLocale, translateMessage } from './i18n.mjs';
|
||||
|
||||
installRendererDiagnosticBoundary();
|
||||
|
||||
const outputPathGuards = new Map();
|
||||
|
||||
// Common CLI head: node render-<type>.mjs [input.json] [output.html]
|
||||
// Keep this synchronous because callers also use it to establish the guarded
|
||||
// output path before testing a last-moment filesystem alias change.
|
||||
export function loadDiagram({ rendererDir, diagramType, defaultExample, argv = process.argv }) {
|
||||
const skillRoot = path.resolve(rendererDir, '../..');
|
||||
const inputPath = path.resolve(argv[2] || path.join(skillRoot, 'examples', defaultExample));
|
||||
const diagram = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
|
||||
validateSchema(diagramType, diagram);
|
||||
validateGuidedViews(diagramType, diagram);
|
||||
validateRelationshipIds(diagramType, diagram);
|
||||
validateEngineeringProfile(diagramType, diagram);
|
||||
const sourceEvidence = verifyRepositoryEvidence(diagramType, diagram, process.env.ARCHIFY_REPO_ROOT);
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets/template.html'), 'utf8');
|
||||
const outputRequest = {
|
||||
requestedOutput: argv[3],
|
||||
authoredOutput: diagram.meta?.output,
|
||||
defaultOutput: `${diagramType}.html`,
|
||||
inputPaths: [inputPath],
|
||||
cwd: process.cwd(),
|
||||
};
|
||||
const { outputPath: outPath } = resolveOutputPath(outputRequest);
|
||||
outputPathGuards.set(outPath, outputRequest);
|
||||
return { diagram, template, outPath, sourceEvidence };
|
||||
}
|
||||
|
||||
// Brand URL capture is the only asynchronous authoring step. Typed renderers
|
||||
// opt into it through this wrapper without changing loadDiagram's long-lived
|
||||
// synchronous safety contract.
|
||||
export async function loadDiagramWithBrandMarks(options) {
|
||||
const loaded = loadDiagram(options);
|
||||
await prepareDiagramBrandMarks(options.diagramType, loaded.diagram);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
const START_TYPES = new Set(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']);
|
||||
|
||||
// Common CLI tail: fill the template and write the standalone HTML file.
|
||||
export function writeDiagram({ outPath, template, diagramType, meta, svg, cards, sourceEvidence = null }) {
|
||||
if (!START_TYPES.has(diagramType)) throw new Error(`writeDiagram: unknown diagram type ${JSON.stringify(diagramType)}`);
|
||||
const outputGuard = outputPathGuards.get(outPath);
|
||||
if (outputGuard) resolveOutputPath(outputGuard);
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
fs.writeFileSync(outPath, applyTemplate(template, {
|
||||
title: meta.title,
|
||||
subtitle: meta.subtitle,
|
||||
svg,
|
||||
cards: renderCards(cards),
|
||||
locale: meta.locale,
|
||||
visualPreset: meta.visual_preset || 'classic',
|
||||
guidedViews: meta.views || [],
|
||||
sourceEvidence,
|
||||
}));
|
||||
outputPathGuards.delete(outPath);
|
||||
console.log(outPath);
|
||||
}
|
||||
|
||||
const SEMANTIC_COLLECTIONS = {
|
||||
architecture: 'components',
|
||||
workflow: 'nodes',
|
||||
sequence: 'participants',
|
||||
dataflow: 'nodes',
|
||||
lifecycle: 'states',
|
||||
};
|
||||
|
||||
const RELATIONSHIP_COLLECTIONS = {
|
||||
architecture: 'connections',
|
||||
workflow: 'edges',
|
||||
sequence: 'messages',
|
||||
dataflow: 'flows',
|
||||
lifecycle: 'transitions',
|
||||
};
|
||||
|
||||
// Relationship IDs are optional for backwards compatibility, but once an
|
||||
// author supplies one it becomes the durable identity used by viewer links.
|
||||
// Keep uniqueness enforcement in the shared zero-install path so every typed
|
||||
// renderer fails the same way even when development dependencies are absent.
|
||||
export function validateRelationshipIds(diagramType, diagram) {
|
||||
const collection = RELATIONSHIP_COLLECTIONS[diagramType];
|
||||
const relationships = collection && Array.isArray(diagram[collection]) ? diagram[collection] : [];
|
||||
const seen = new Set();
|
||||
const problems = [];
|
||||
|
||||
relationships.forEach((relationship, index) => {
|
||||
if (relationship.id === undefined || relationship.id === null || relationship.id === '') return;
|
||||
if (seen.has(relationship.id)) {
|
||||
problems.push(`/${collection}/${index}/id duplicates relationship id ${JSON.stringify(relationship.id)}`);
|
||||
}
|
||||
seen.add(relationship.id);
|
||||
});
|
||||
|
||||
if (problems.length) {
|
||||
throwDiagnosticProblems('Relationship identity validation failed', problems, {
|
||||
code: 'relationship/duplicate-id',
|
||||
subject: { diagramType, collection },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// JSON Schema keeps the view object bounded; this pass checks facts that span
|
||||
// collections. Keeping it here makes the same contract apply to all five
|
||||
// renderers, including the zero-install standalone-validator path.
|
||||
export function validateGuidedViews(diagramType, diagram) {
|
||||
const views = diagram.meta?.views;
|
||||
if (!Array.isArray(views) || views.length === 0) return;
|
||||
const collection = SEMANTIC_COLLECTIONS[diagramType];
|
||||
const semanticIds = new Set((diagram[collection] || []).map((item) => item.id));
|
||||
const seen = new Set();
|
||||
const problems = [];
|
||||
|
||||
views.forEach((view, index) => {
|
||||
if (seen.has(view.id)) problems.push(`/meta/views/${index}/id duplicates view id ${JSON.stringify(view.id)}`);
|
||||
seen.add(view.id);
|
||||
const seenFocus = new Set();
|
||||
(view.focus || []).forEach((id, focusIndex) => {
|
||||
if (seenFocus.has(id)) {
|
||||
problems.push(`/meta/views/${index}/focus/${focusIndex} duplicates semantic id ${JSON.stringify(id)}`);
|
||||
}
|
||||
seenFocus.add(id);
|
||||
if (!semanticIds.has(id)) {
|
||||
problems.push(`/meta/views/${index}/focus/${focusIndex} references unknown semantic id ${JSON.stringify(id)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (problems.length) {
|
||||
throwDiagnosticProblems('Guided view validation failed', problems, {
|
||||
code: 'guided-view/invalid',
|
||||
subject: { diagramType, collection: 'meta.views' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Accessible name for the generated diagram SVG.
|
||||
export function svgRootAttrs(meta) {
|
||||
const animation = meta.animation === 'trace' ? ' data-animation="trace"' : '';
|
||||
const preset = ` data-preset="${esc(meta.visual_preset || 'classic')}"`;
|
||||
const engineeringProfile = meta.engineering_profile
|
||||
? ` data-engineering-profile="${esc(meta.engineering_profile)}"`
|
||||
: '';
|
||||
const requestedProfile = process.env.ARCHIFY_QUALITY_PROFILE || meta.quality_profile;
|
||||
const qualityProfile = requestedProfile === 'showcase' ? 'showcase' : 'standard';
|
||||
const advisory = requestedProfile ? '' : ' data-quality-gates="advisory"';
|
||||
return `role="img" lang="${esc(resolveLocale(meta.locale))}" aria-labelledby="archify-diagram-title archify-diagram-description"${animation}${preset}${engineeringProfile} data-quality-profile="${esc(qualityProfile)}"${advisory}`;
|
||||
}
|
||||
|
||||
// Keep the accessible name inside the SVG so it survives standalone SVG
|
||||
// export and embedding. The fixed IDs are deterministic because an Archify
|
||||
// artifact intentionally contains one primary diagram SVG.
|
||||
export function svgAccessibleText(meta, kind) {
|
||||
const description = meta.subtitle || translateMessage(meta.locale, `diagram.description.${kind}`);
|
||||
return ` <title id="archify-diagram-title">${esc(meta.title)}</title>\n <desc id="archify-diagram-description">${esc(description)}</desc>`;
|
||||
}
|
||||
|
||||
export function animateAttr(meta, kind, step) {
|
||||
if (meta.animation !== 'trace') return '';
|
||||
// Ambient trace must finish inside the fixed six-second WebM capture. The
|
||||
// cap affects visual delay only; authored order and semantic identity stay
|
||||
// untouched in the JSON, DOM, Story, and relationship contracts.
|
||||
const safeStep = Number.isFinite(step) && step >= 0 ? Math.min(12, Math.floor(step)) : 0;
|
||||
return ` data-animate="${kind}" style="--step:${safeStep}"`;
|
||||
}
|
||||
|
||||
// Stable semantic hooks for the standalone HTML explorer. IDs already pass
|
||||
// the schema's conservative identifier pattern; escape again at the markup
|
||||
// boundary so these helpers remain safe if that contract expands later.
|
||||
export function focusNodeAttrs(id, label, metadata = {}, locale) {
|
||||
const optional = [
|
||||
['data-node-kind', metadata.kind],
|
||||
['data-node-sublabel', metadata.sublabel],
|
||||
['data-node-tag', metadata.tag],
|
||||
['data-node-context', metadata.context],
|
||||
['data-node-brand', metadata.brand],
|
||||
['data-node-brand-id', metadata.brandId],
|
||||
['data-node-brand-status', metadata.brandStatus],
|
||||
['data-node-brand-source', metadata.brandSource],
|
||||
].filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '')
|
||||
.map(([name, value]) => ` ${name}="${esc(String(value))}"`)
|
||||
.join('');
|
||||
const detail = [metadata.sublabel, metadata.context, metadata.brand]
|
||||
.filter((value) => value !== undefined && value !== null && String(value).trim() !== '')
|
||||
.join(', ');
|
||||
const aria = detail
|
||||
? translateMessage(locale, 'node.focus.detail', { label, detail })
|
||||
: translateMessage(locale, 'node.focus', { label });
|
||||
return `id="node-${esc(id)}" data-node-id="${esc(id)}" data-node-label="${esc(label)}" tabindex="0" role="button" aria-label="${esc(aria)}" aria-pressed="false"${optional}`;
|
||||
}
|
||||
|
||||
// Native SVG titles preserve a compact details-on-demand fallback when the
|
||||
// canonical SVG is embedded inline outside the full Archify viewer.
|
||||
export function focusNodeTitle(label, metadata = {}) {
|
||||
const parts = [label, metadata.sublabel, metadata.context, metadata.tag, metadata.brand]
|
||||
.filter((value) => value !== undefined && value !== null && String(value).trim() !== '');
|
||||
return `<title>${esc(parts.join(' · '))}</title>`;
|
||||
}
|
||||
|
||||
export function focusEdgeAttrs(from, to, label, key, id) {
|
||||
const named = label ? ` data-edge-label="${esc(label)}"` : '';
|
||||
const keyed = key !== undefined && key !== null ? ` data-edge-key="${esc(String(key))}"` : '';
|
||||
const identified = id !== undefined && id !== null && String(id).trim() !== ''
|
||||
? ` data-edge-id="${esc(String(id))}"`
|
||||
: '';
|
||||
return `data-edge-from="${esc(from)}" data-edge-to="${esc(to)}"${named}${keyed}${identified}`;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export const DESKTOP_READABILITY_VIEWPORT = Object.freeze({ width: 1440, height: 900 });
|
||||
export const DESKTOP_READER_MIN_WIDTH = 960;
|
||||
export const DESKTOP_READER_HORIZONTAL_CHROME = 30;
|
||||
export const DESKTOP_READER_DIAGRAM_WIDTH = DESKTOP_READER_MIN_WIDTH - DESKTOP_READER_HORIZONTAL_CHROME;
|
||||
export const MIN_PROJECTED_NODE_TEXT_PX = 6;
|
||||
|
||||
export function projectedNodeTextPx(sourceFontPx, viewBoxWidth, diagramWidth = DESKTOP_READER_DIAGRAM_WIDTH) {
|
||||
if (![sourceFontPx, viewBoxWidth, diagramWidth].every(Number.isFinite) || viewBoxWidth <= 0 || diagramWidth <= 0) {
|
||||
return Number.NaN;
|
||||
}
|
||||
return sourceFontPx * Math.min(1, diagramWidth / viewBoxWidth);
|
||||
}
|
||||
|
||||
export function minimumReadableSourceTextPx(
|
||||
viewBoxWidth,
|
||||
diagramWidth = DESKTOP_READER_DIAGRAM_WIDTH,
|
||||
minimumProjectedPx = MIN_PROJECTED_NODE_TEXT_PX,
|
||||
) {
|
||||
if (![viewBoxWidth, diagramWidth, minimumProjectedPx].every(Number.isFinite)
|
||||
|| viewBoxWidth <= 0
|
||||
|| diagramWidth <= 0
|
||||
|| minimumProjectedPx <= 0) {
|
||||
return Number.NaN;
|
||||
}
|
||||
return minimumProjectedPx / Math.min(1, diagramWidth / viewBoxWidth);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const DIAGNOSTIC_MODE = process.env.ARCHIFY_DIAGNOSTIC_FORMAT === 'json';
|
||||
const recorded = [];
|
||||
const recordedMessages = new Set();
|
||||
const boundaryKey = Symbol.for('archify.renderer-diagnostic-boundary');
|
||||
let recordingSuppressionDepth = 0;
|
||||
|
||||
function plainObject(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
||||
}
|
||||
|
||||
function normalizedDiagnostic(diagnostic) {
|
||||
const message = String(diagnostic?.message || 'Archify could not classify this failure.').trim();
|
||||
return {
|
||||
code: String(diagnostic?.code || 'internal/unclassified'),
|
||||
severity: diagnostic?.severity === 'warning' ? 'warning' : 'error',
|
||||
message,
|
||||
subject: plainObject(diagnostic?.subject),
|
||||
evidence: plainObject(diagnostic?.evidence),
|
||||
supportedFixes: Array.isArray(diagnostic?.supportedFixes)
|
||||
? [...new Set(diagnostic.supportedFixes.map((fix) => String(fix).trim()).filter(Boolean))]
|
||||
: [],
|
||||
...(Array.isArray(diagnostic?.suppresses) ? {
|
||||
suppresses: [...new Set(diagnostic.suppresses.map((code) => String(code).trim()).filter(Boolean))],
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function recordDiagnostic(diagnostic) {
|
||||
if (!DIAGNOSTIC_MODE || recordingSuppressionDepth > 0) return;
|
||||
const normalized = normalizedDiagnostic(diagnostic);
|
||||
if (recordedMessages.has(normalized.message)) return;
|
||||
recordedMessages.add(normalized.message);
|
||||
recorded.push(normalized);
|
||||
}
|
||||
|
||||
export function withDiagnosticRecordingSuppressed(callback) {
|
||||
recordingSuppressionDepth += 1;
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
recordingSuppressionDepth -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function throwDiagnosticError(message, diagnostics) {
|
||||
for (const diagnostic of diagnostics || []) recordDiagnostic(diagnostic);
|
||||
const error = new Error(message);
|
||||
error.archifyDiagnostics = (diagnostics || []).map(normalizedDiagnostic);
|
||||
throw error;
|
||||
}
|
||||
|
||||
export function throwDiagnosticProblems(prefix, problems, { code = 'layout/constraint', subject = {} } = {}) {
|
||||
const messages = (problems || []).map((problem) => String(problem));
|
||||
const diagnostics = messages.map((message) => normalizedDiagnostic({
|
||||
code,
|
||||
severity: 'error',
|
||||
message,
|
||||
subject,
|
||||
evidence: {},
|
||||
supportedFixes: [],
|
||||
}));
|
||||
throwDiagnosticError(`${prefix}:\n- ${messages.join('\n- ')}`, diagnostics);
|
||||
}
|
||||
|
||||
function fallbackDiagnostic(error) {
|
||||
const input = process.argv[2] ? path.resolve(process.argv[2]) : undefined;
|
||||
if (error instanceof SyntaxError) {
|
||||
return normalizedDiagnostic({
|
||||
code: 'input/json-parse',
|
||||
severity: 'error',
|
||||
message: `Input JSON could not be parsed: ${error.message}`,
|
||||
subject: { input },
|
||||
evidence: { reason: error.message },
|
||||
supportedFixes: ['repair the JSON syntax and run validation again'],
|
||||
});
|
||||
}
|
||||
if (error?.code === 'ENOENT' || error?.code === 'EACCES' || error?.code === 'EISDIR') {
|
||||
return normalizedDiagnostic({
|
||||
code: 'input/read',
|
||||
severity: 'error',
|
||||
message: `Input could not be read: ${error.message}`,
|
||||
subject: { input },
|
||||
evidence: { systemCode: error.code, reason: error.message },
|
||||
supportedFixes: ['provide one readable JSON input file'],
|
||||
});
|
||||
}
|
||||
return normalizedDiagnostic({
|
||||
code: 'internal/unclassified',
|
||||
severity: 'error',
|
||||
message: error?.message || 'Renderer failed without a diagnostic.',
|
||||
subject: { input },
|
||||
evidence: { errorName: error?.name || 'Error' },
|
||||
supportedFixes: [],
|
||||
});
|
||||
}
|
||||
function rendererFailure(error) {
|
||||
const attached = Array.isArray(error?.archifyDiagnostics)
|
||||
? error.archifyDiagnostics.map(normalizedDiagnostic)
|
||||
: [];
|
||||
const diagnostics = recorded.length ? recorded : (attached.length ? attached : [fallbackDiagnostic(error)]);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
ok: false,
|
||||
source: 'renderer',
|
||||
error: error?.message || 'Renderer failed without a diagnostic.',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
export function installRendererDiagnosticBoundary() {
|
||||
if (!DIAGNOSTIC_MODE || globalThis[boundaryKey]) return;
|
||||
globalThis[boundaryKey] = true;
|
||||
process.on('uncaughtException', (error) => {
|
||||
const payload = `${JSON.stringify(rendererFailure(error))}\n`;
|
||||
try {
|
||||
fs.writeSync(process.stderr.fd, payload);
|
||||
} catch {
|
||||
// The renderer is already failing. Avoid replacing its real error with a
|
||||
// secondary stream failure; the parent CLI still has the exit status.
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { throwDiagnosticError } from './diagnostics.mjs';
|
||||
|
||||
const DEPLOYMENT_PROFILE = 'deployment-ownership';
|
||||
const DEPLOYMENT_BOUNDARY_KINDS = new Set(['region', 'security-group']);
|
||||
const PRIVATE_STATE_TYPES = new Set(['database']);
|
||||
|
||||
function subject(collection, index, item = {}) {
|
||||
return {
|
||||
diagramType: 'architecture',
|
||||
profile: DEPLOYMENT_PROFILE,
|
||||
collection,
|
||||
index,
|
||||
...(item.id ? { id: item.id } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function membership(boundaries, componentId, kind) {
|
||||
return boundaries
|
||||
.map((boundary, index) => ({ boundary, index }))
|
||||
.filter(({ boundary }) => boundary.kind === kind && boundary.wraps.includes(componentId));
|
||||
}
|
||||
|
||||
export function deploymentOwnershipDiagnostics(diagram) {
|
||||
const components = Array.isArray(diagram.components) ? diagram.components : [];
|
||||
const boundaries = (Array.isArray(diagram.boundaries) ? diagram.boundaries : [])
|
||||
.map((boundary) => ({ ...boundary, wraps: Array.isArray(boundary.wraps) ? boundary.wraps : [] }));
|
||||
const connections = Array.isArray(diagram.connections) ? diagram.connections : [];
|
||||
const diagnostics = [];
|
||||
|
||||
for (const kind of DEPLOYMENT_BOUNDARY_KINDS) {
|
||||
const count = boundaries.filter((boundary) => boundary.kind === kind).length;
|
||||
if (count > 0) continue;
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-boundary-kind',
|
||||
severity: 'error',
|
||||
message: `Deployment ownership requires at least one ${kind} boundary.`,
|
||||
subject: subject('boundaries', -1),
|
||||
evidence: { requiredKind: kind, found: count },
|
||||
supportedFixes: [`add one ${kind} boundary with an explicit wraps list`],
|
||||
});
|
||||
}
|
||||
|
||||
components.forEach((component, index) => {
|
||||
if (component.type === 'external') return;
|
||||
if (typeof component.tag !== 'string' || component.tag.trim() === '') {
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-owner-missing',
|
||||
severity: 'error',
|
||||
message: `Deployment component ${JSON.stringify(component.id)} does not name its owner in tag.`,
|
||||
subject: subject('components', index, component),
|
||||
evidence: { componentType: component.type, ownerField: 'tag' },
|
||||
supportedFixes: [`set /components/${index}/tag to the responsible team or owner`],
|
||||
});
|
||||
}
|
||||
|
||||
const regions = membership(boundaries, component.id, 'region');
|
||||
if (regions.length === 0) {
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-region-scope',
|
||||
severity: 'error',
|
||||
message: `Deployment component ${JSON.stringify(component.id)} is not assigned to a region boundary.`,
|
||||
subject: subject('components', index, component),
|
||||
evidence: { componentType: component.type, regionMemberships: 0 },
|
||||
supportedFixes: ['add the component id to the real region boundary wraps list'],
|
||||
});
|
||||
} else if (regions.length > 1) {
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-region-ambiguous',
|
||||
severity: 'error',
|
||||
message: `Deployment component ${JSON.stringify(component.id)} belongs to more than one region boundary.`,
|
||||
subject: subject('components', index, component),
|
||||
evidence: {
|
||||
componentType: component.type,
|
||||
regions: regions.map(({ boundary, index: boundaryIndex }) => ({ boundaryIndex, label: boundary.label })),
|
||||
},
|
||||
supportedFixes: ['keep the component id in exactly one real region boundary wraps list'],
|
||||
});
|
||||
}
|
||||
|
||||
if (PRIVATE_STATE_TYPES.has(component.type)) {
|
||||
const privateScopes = membership(boundaries, component.id, 'security-group');
|
||||
if (privateScopes.length === 0) {
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-private-state',
|
||||
severity: 'error',
|
||||
message: `Stateful component ${JSON.stringify(component.id)} is not assigned to a private security-group boundary.`,
|
||||
subject: subject('components', index, component),
|
||||
evidence: { componentType: component.type, privateMemberships: 0 },
|
||||
supportedFixes: ['add the component id to the real private security-group boundary wraps list'],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
boundaries.forEach((boundary, index) => {
|
||||
if (boundary.kind !== 'security-group') return;
|
||||
const members = boundary.wraps.map((id) => ({
|
||||
id,
|
||||
regions: membership(boundaries, id, 'region').map(({ boundary: region, index: boundaryIndex }) => ({
|
||||
boundaryIndex,
|
||||
label: region.label,
|
||||
})),
|
||||
}));
|
||||
const regionIndexes = new Set(members.flatMap((member) => member.regions.map((region) => region.boundaryIndex)));
|
||||
const consistent = members.length > 0
|
||||
&& members.every((member) => member.regions.length === 1)
|
||||
&& regionIndexes.size === 1;
|
||||
if (consistent) return;
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-private-region-consistency',
|
||||
severity: 'error',
|
||||
message: `Private boundary ${JSON.stringify(boundary.label)} must contain components from exactly one shared region.`,
|
||||
subject: subject('boundaries', index, boundary),
|
||||
evidence: { boundaryKind: boundary.kind, members },
|
||||
supportedFixes: ['assign every private-boundary component to exactly one shared region boundary'],
|
||||
});
|
||||
});
|
||||
|
||||
connections.forEach((connection, index) => {
|
||||
const crossedBoundaries = boundaries
|
||||
.map((boundary, boundaryIndex) => ({
|
||||
boundaryIndex,
|
||||
kind: boundary.kind,
|
||||
label: boundary.label,
|
||||
fromInside: boundary.wraps.includes(connection.from),
|
||||
toInside: boundary.wraps.includes(connection.to),
|
||||
}))
|
||||
.filter((boundary) => DEPLOYMENT_BOUNDARY_KINDS.has(boundary.kind) && boundary.fromInside !== boundary.toInside);
|
||||
if (crossedBoundaries.length === 0 || (typeof connection.label === 'string' && connection.label.trim() !== '')) return;
|
||||
diagnostics.push({
|
||||
code: 'engineering/deployment-crossing-mechanism',
|
||||
severity: 'error',
|
||||
message: `Cross-boundary connection ${JSON.stringify(connection.id || `${connection.from}->${connection.to}`)} does not name its mechanism.`,
|
||||
subject: subject('connections', index, connection),
|
||||
evidence: {
|
||||
from: connection.from,
|
||||
to: connection.to,
|
||||
crossedBoundaries: crossedBoundaries.map(({ boundaryIndex, kind, label }) => ({ boundaryIndex, kind, label })),
|
||||
},
|
||||
supportedFixes: [`set /connections/${index}/label to the real cross-boundary mechanism`],
|
||||
});
|
||||
});
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
export function validateEngineeringProfile(diagramType, diagram) {
|
||||
const profile = diagram.meta?.engineering_profile;
|
||||
if (!profile) return;
|
||||
if (diagramType !== 'architecture' || profile !== DEPLOYMENT_PROFILE) return;
|
||||
const diagnostics = deploymentOwnershipDiagnostics(diagram);
|
||||
if (!diagnostics.length) return;
|
||||
throwDiagnosticError(
|
||||
`Engineering profile ${JSON.stringify(profile)} failed:\n${diagnostics.map((entry) => `- ${entry.message}`).join('\n')}`,
|
||||
diagnostics,
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,594 @@
|
||||
export const SUPPORTED_LOCALES = ['en', 'zh-CN'];
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
|
||||
const ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, (character) => ESCAPE_MAP[character]);
|
||||
}
|
||||
|
||||
// One catalog feeds renderer-time SVG/HTML copy and the selected runtime
|
||||
// catalog embedded in each standalone artifact. Keeping every locale in one
|
||||
// tuple makes missing translations impossible to hide behind an English
|
||||
// fallback during development.
|
||||
const MESSAGE_PAIRS = {
|
||||
'page.title': ['{title} Diagram', '{title}'],
|
||||
'diagram.description.architecture': ['An architecture diagram generated by Archify.', '由 Archify 生成的架构图。'],
|
||||
'diagram.description.workflow': ['A workflow diagram generated by Archify.', '由 Archify 生成的工作流图。'],
|
||||
'diagram.description.sequence': ['A sequence diagram generated by Archify.', '由 Archify 生成的时序图。'],
|
||||
'diagram.description.dataflow': ['A data-flow diagram generated by Archify.', '由 Archify 生成的数据流图。'],
|
||||
'diagram.description.lifecycle': ['A lifecycle diagram generated by Archify.', '由 Archify 生成的生命周期图。'],
|
||||
'node.focus': ['Focus {label}', '聚焦{label}'],
|
||||
'node.focus.detail': ['Focus {label}, {detail}', '聚焦{label},{detail}'],
|
||||
'node.context.architecture': ['Architecture component', '架构组件'],
|
||||
'node.context.workflow': ['Workflow node', '工作流节点'],
|
||||
'node.context.sequence': ['Sequence participant', '时序参与者'],
|
||||
'node.context.dataflow': ['Data-flow node', '数据流节点'],
|
||||
'node.context.lifecycle': ['Lifecycle state', '生命周期状态'],
|
||||
'legend.title': ['Legend', '图例'],
|
||||
|
||||
'legend.architecture.frontend': ['Frontend', '前端'],
|
||||
'legend.architecture.backend': ['Backend', '后端'],
|
||||
'legend.architecture.database': ['Database', '数据库'],
|
||||
'legend.architecture.cloud': ['Cloud', '云服务'],
|
||||
'legend.architecture.security': ['Security', '安全'],
|
||||
'legend.architecture.messagebus': ['Message bus', '消息总线'],
|
||||
'legend.architecture.external': ['External', '外部系统'],
|
||||
'legend.workflow.frontend': ['User UI', '用户界面'],
|
||||
'legend.workflow.backend': ['Agent logic', 'Agent 逻辑'],
|
||||
'legend.workflow.security': ['Policy', '策略'],
|
||||
'legend.workflow.messagebus': ['Tool action', '工具操作'],
|
||||
'legend.workflow.database': ['Context / trace', '上下文 / 追踪'],
|
||||
'legend.workflow.cloud': ['Cloud service', '云服务'],
|
||||
'legend.workflow.external': ['External system', '外部系统'],
|
||||
'legend.sequence.emphasis': ['request', '请求'],
|
||||
'legend.sequence.return': ['return', '返回'],
|
||||
'legend.sequence.security': ['security', '安全'],
|
||||
'legend.sequence.dashed': ['async trace', '异步追踪'],
|
||||
'legend.sequence.default': ['default message', '默认消息'],
|
||||
'legend.dataflow.emphasis': ['primary data', '主要数据'],
|
||||
'legend.dataflow.security': ['policy / PII', '策略 / PII'],
|
||||
'legend.dataflow.dashed': ['async batch', '异步批处理'],
|
||||
'legend.dataflow.database': ['data store', '数据存储'],
|
||||
'legend.dataflow.default': ['data flow', '数据流'],
|
||||
'legend.lifecycle.start': ['start', '开始'],
|
||||
'legend.lifecycle.active': ['active state', '活动状态'],
|
||||
'legend.lifecycle.waiting': ['waiting', '等待'],
|
||||
'legend.lifecycle.decision': ['decision', '决策'],
|
||||
'legend.lifecycle.success': ['terminal success', '成功终态'],
|
||||
'legend.lifecycle.failure': ['failure / exit', '失败 / 退出'],
|
||||
'legend.lifecycle.neutral': ['neutral', '中性状态'],
|
||||
'legend.lifecycle.external': ['external', '外部状态'],
|
||||
|
||||
'viewer.kind.frontend': ['Frontend', '前端'],
|
||||
'viewer.kind.backend': ['Backend', '后端'],
|
||||
'viewer.kind.database': ['Database', '数据库'],
|
||||
'viewer.kind.cloud': ['Cloud', '云服务'],
|
||||
'viewer.kind.security': ['Security', '安全'],
|
||||
'viewer.kind.messagebus': ['Message bus', '消息总线'],
|
||||
'viewer.kind.external': ['External', '外部系统'],
|
||||
'viewer.kind.neutral': ['Neutral', '中性'],
|
||||
'viewer.kind.node': ['Node', '节点'],
|
||||
'viewer.kind.start': ['Start', '开始'],
|
||||
'viewer.kind.active': ['Active', '活动'],
|
||||
'viewer.kind.waiting': ['Waiting', '等待'],
|
||||
'viewer.kind.decision': ['Decision', '决策'],
|
||||
'viewer.kind.success': ['Success', '成功'],
|
||||
'viewer.kind.failure': ['Failure', '失败'],
|
||||
|
||||
'viewer.toolbar.actions': ['Diagram actions', '图表操作'],
|
||||
'viewer.theme.toggle.title': ['Toggle theme (T)', '切换主题(T)'],
|
||||
'viewer.theme.toggle': ['Toggle color theme', '切换颜色主题'],
|
||||
'viewer.theme.dark': ['Dark', '深色'],
|
||||
'viewer.theme.light': ['Light', '浅色'],
|
||||
'viewer.preset.choose.title': ['Choose visual style (S cycles)', '选择视觉风格(S 循环切换)'],
|
||||
'viewer.preset.choose': ['Choose visual style', '选择视觉风格'],
|
||||
'viewer.preset.style': ['Style', '风格'],
|
||||
'viewer.preset.menu': ['Visual style', '视觉风格'],
|
||||
'viewer.preset.identity': ['Visual identity', '视觉表达'],
|
||||
'viewer.preset.cycles': ['S cycles', 'S 循环切换'],
|
||||
'viewer.preset.classic': ['Classic', '经典'],
|
||||
'viewer.preset.classic.short': ['Classic', '经典'],
|
||||
'viewer.preset.classic.hint': ['Stable technical default', '稳定的技术默认风格'],
|
||||
'viewer.preset.flow': ['Signal Flow', '信号流'],
|
||||
'viewer.preset.flow.short': ['Flow', '流动'],
|
||||
'viewer.preset.flow.hint': ['Motion-forward presentation', '突出动态流向'],
|
||||
'viewer.preset.blueprint': ['Blueprint', '蓝图'],
|
||||
'viewer.preset.blueprint.hint': ['Engineering review', '工程评审'],
|
||||
'viewer.preset.editorial': ['Editorial', '编辑风格'],
|
||||
'viewer.preset.editorial.hint': ['Publication and launch notes', '适合发布与上线说明'],
|
||||
'viewer.preset.badge.signalFlow': ['SIGNAL FLOW', '信号流'],
|
||||
'viewer.preset.badge.blueprint': ['BLUEPRINT / REV 01', '蓝图 / 修订 01'],
|
||||
'viewer.preset.badge.editorial': ['EDITORIAL / FIELD NOTE', '编辑风格 / 现场笔记'],
|
||||
'viewer.preset.badge.editorialPlate': ['ARCHIFY / PLATE 04', 'ARCHIFY / 图版 04'],
|
||||
'viewer.preset.current': ['Visual style: {style}. Choose visual style', '当前视觉风格:{style}。选择视觉风格'],
|
||||
'viewer.motion.live': ['Live', '动态'],
|
||||
'viewer.motion.still': ['Still', '静态'],
|
||||
'viewer.motion.pause': ['Pause motion', '暂停动效'],
|
||||
'viewer.motion.resume': ['Resume motion', '恢复动效'],
|
||||
'viewer.motion.reduced': ['Motion paused by reduced-motion preference', '已根据减少动态效果偏好暂停动效'],
|
||||
'viewer.motion.hidden': ['Motion paused while this page is hidden', '页面不可见时已暂停动效'],
|
||||
'viewer.motion.yielding': ['Pause motion; currently yielding to {owner}', '暂停动效;当前让位于{owner}'],
|
||||
'viewer.motion.yielding.title': ['Live preview enabled · yielding to {owner}', '动态预览已启用 · 正在让位于{owner}'],
|
||||
'viewer.owner.story': ['the guided story', '引导故事'],
|
||||
'viewer.owner.chapter': ['the active chapter', '当前章节'],
|
||||
'viewer.owner.chapterPreview': ['the chapter delta preview', '章节差异预览'],
|
||||
'viewer.owner.handoff': ['the chapter handoff', '章节交接'],
|
||||
'viewer.owner.route': ['Route Probe', '路径探测'],
|
||||
'viewer.owner.lens': ['Semantic Lens', '语义透镜'],
|
||||
'viewer.owner.relationship': ['Relationship Preview', '关系预览'],
|
||||
'viewer.owner.intent': ['Intent Trace', '意图追踪'],
|
||||
'viewer.owner.focus': ['semantic focus', '语义聚焦'],
|
||||
'viewer.owner.legend': ['legend preview', '图例预览'],
|
||||
'viewer.owner.reader': ['reader interaction', '读者交互'],
|
||||
'viewer.present.enter': ['Enter presentation stage', '进入演示模式'],
|
||||
'viewer.present.enter.title': ['Presentation stage (F)', '演示模式(F)'],
|
||||
'viewer.present.exit': ['Exit presentation stage', '退出演示模式'],
|
||||
'viewer.present.exit.title': ['Exit presentation stage (F or Escape)', '退出演示模式(F 或 Escape)'],
|
||||
'viewer.present.present': ['Present', '演示'],
|
||||
'viewer.present.exit.label': ['Exit', '退出'],
|
||||
|
||||
'viewer.export.button': ['Export', '导出'],
|
||||
'viewer.export.button.title': ['Export diagram (E)', '导出图表(E)'],
|
||||
'viewer.export.diagram': ['Export diagram', '导出图表'],
|
||||
'viewer.export.menu': ['Export', '导出'],
|
||||
'viewer.export.subtitle': ['Portable, clean outputs', '便携、整洁的输出'],
|
||||
'viewer.export.share': ['Share', '分享'],
|
||||
'viewer.export.shareCard': ['Share Card', '分享卡片'],
|
||||
'viewer.export.routeShareCard': ['Route Share Card', '路径分享卡片'],
|
||||
'viewer.export.reachShareCard': ['Reach Share Card', '可达范围分享卡片'],
|
||||
'viewer.export.copyShareCard': ['Copy Share Card', '复制分享卡片'],
|
||||
'viewer.export.copyDiagram': ['Copy diagram', '复制图表'],
|
||||
'viewer.export.clipboardPng': ['PNG to clipboard', '复制 PNG 到剪贴板'],
|
||||
'viewer.export.raster': ['Raster images', '位图'],
|
||||
'viewer.export.image': ['Image', '图像'],
|
||||
'viewer.export.lossless': ['Lossless image', '无损图像'],
|
||||
'viewer.export.compact': ['Compact image', '紧凑图像'],
|
||||
'viewer.export.modern': ['Modern image', '现代图像格式'],
|
||||
'viewer.export.vectorMotion': ['Vector and motion', '矢量与动效'],
|
||||
'viewer.export.vectorMotion.heading': ['Vector & motion', '矢量与动效'],
|
||||
'viewer.export.editable': ['Editable vector', '可编辑矢量图'],
|
||||
'viewer.export.motion6s': ['6s motion', '6 秒动效'],
|
||||
'viewer.export.unsupported': ['Not supported by this browser', '当前浏览器不支持'],
|
||||
'viewer.export.clipboardUnsupported': ['Clipboard image write not supported by this browser', '当前浏览器不支持写入图片剪贴板'],
|
||||
'viewer.export.clipboardUnsupported.period': ['Clipboard image write not supported by this browser.', '当前浏览器不支持写入图片剪贴板。'],
|
||||
'viewer.export.clipboardUnsupported.short': ['Clipboard image write not supported in this browser.', '此浏览器不支持写入图片剪贴板。'],
|
||||
'viewer.export.motionUnavailable': ['Motion capture unavailable in this browser', '当前浏览器无法录制动效'],
|
||||
'viewer.export.webmUnavailable': ['WebM unavailable in this browser', '当前浏览器不支持 WebM'],
|
||||
'viewer.export.failed': ['Export failed: {message}', '导出失败:{message}'],
|
||||
'viewer.export.unknownVariant': ['Unknown Share Card variant: {variant}', '未知的分享卡片类型:{variant}'],
|
||||
'viewer.export.routeRequired': ['Trace a route before exporting a Route Share Card', '请先追踪路径,再导出路径分享卡片'],
|
||||
'viewer.export.reachRequired': ['Trace authored reach before exporting a Reach Share Card', '请先追踪编写可达范围,再导出可达范围分享卡片'],
|
||||
'viewer.export.unknown': ['unknown', '未知错误'],
|
||||
'viewer.export.routeFailed': ['Route Share Card export failed: {message}', '路径分享卡片导出失败:{message}'],
|
||||
'viewer.export.reachFailed': ['Reach Share Card export failed: {message}', '可达范围分享卡片导出失败:{message}'],
|
||||
'viewer.export.copyFailed': ['Copy failed: {message}', '复制失败:{message}'],
|
||||
'viewer.export.copiedPng': ['Copied PNG to clipboard', '已将 PNG 复制到剪贴板'],
|
||||
'viewer.export.copiedShare': ['Copied Share Card', '已复制分享卡片'],
|
||||
'viewer.export.downloadedShare': ['Downloaded Share Card', '已下载分享卡片'],
|
||||
'viewer.export.downloadedRoute': ['Downloaded Route Share Card', '已下载路径分享卡片'],
|
||||
'viewer.export.downloadedReach': ['Downloaded Reach Share Card', '已下载可达范围分享卡片'],
|
||||
'viewer.export.downloadedWebm': ['Downloaded WebM', '已下载 WebM'],
|
||||
'viewer.export.recording': ['Recording 6 seconds of motion…', '正在录制 6 秒动效…'],
|
||||
'viewer.export.card.routeSummary.one': ['Route: {source} → {target} · {count} directed hop', '路径:{source} → {target} · {count} 个有向跳转'],
|
||||
'viewer.export.card.routeSummary.other': ['Route: {source} → {target} · {count} directed hops', '路径:{source} → {target} · {count} 个有向跳转'],
|
||||
'viewer.export.card.reachSummary': ['Authored {direction} from {origin} · {nodes} · {links} · max {hops}', '从{origin}开始的编写{direction} · {nodes} · {links} · 最深 {hops}'],
|
||||
'viewer.export.card.node.one': ['{count} node', '{count} 个节点'],
|
||||
'viewer.export.card.node.other': ['{count} nodes', '{count} 个节点'],
|
||||
'viewer.export.card.link.one': ['{count} link', '{count} 条连接'],
|
||||
'viewer.export.card.link.other': ['{count} links', '{count} 条连接'],
|
||||
'viewer.export.card.hop.one': ['{count} hop', '{count} 跳'],
|
||||
'viewer.export.card.hop.other': ['{count} hops', '{count} 跳'],
|
||||
'viewer.export.card.routeBadge': ['ARCHIFY · ROUTE · {hops}', 'ARCHIFY · 路径 · {hops}'],
|
||||
'viewer.export.card.reachBadge': ['ARCHIFY · {direction} REACH', 'ARCHIFY · {direction}可达范围'],
|
||||
'viewer.export.card.defaultBadge': ['ARCHIFY · {preset} · {theme}', 'ARCHIFY · {preset} · {theme}'],
|
||||
'viewer.export.direction.upstream': ['Upstream', '上游'],
|
||||
'viewer.export.direction.downstream': ['Downstream', '下游'],
|
||||
'viewer.export.error.canvasUnavailable': ['Canvas unavailable for {label}', '无法为{label}使用画布'],
|
||||
'viewer.export.error.contextUnavailable': ['2D canvas context unavailable for {label}', '无法为{label}创建二维画布上下文'],
|
||||
'viewer.export.error.toBlobUnavailable': ['canvas.toBlob unavailable for {label}', '{label}无法使用 canvas.toBlob'],
|
||||
'viewer.export.error.toBlobNull': ['canvas.toBlob returned no data for {label}', '{label}的 canvas.toBlob 未返回数据'],
|
||||
'viewer.export.error.variantsCombined': ['Share Card variants cannot be combined', '无法同时组合多种分享卡片类型'],
|
||||
'viewer.export.error.viewerState': ['Share Card export could not remove temporary viewer state', '分享卡片导出无法移除临时 Viewer 状态'],
|
||||
'viewer.export.error.routeState': ['Route Card export could not preserve the resolved route safely', '路径卡片导出无法安全保留已解析路径'],
|
||||
'viewer.export.error.reachState': ['Reach Card export could not preserve authored reach safely', '可达范围卡片导出无法安全保留编写的可达范围'],
|
||||
'viewer.export.error.webmRequirements': ['WebM motion export requires a trace animation and browser MediaRecorder support', 'WebM 动效导出需要追踪动画及浏览器 MediaRecorder 支持'],
|
||||
'viewer.export.error.mediaRecorder': ['MediaRecorder failed', 'MediaRecorder 录制失败'],
|
||||
'viewer.export.error.emptyWebm': ['MediaRecorder produced an empty WebM', 'MediaRecorder 生成了空的 WebM'],
|
||||
'viewer.export.error.webmBackground': ['SVG background could not be loaded for WebM export', '无法为 WebM 导出加载 SVG 背景'],
|
||||
|
||||
'viewer.guided.region': ['Guided diagram views', '图表引导视图'],
|
||||
'viewer.guided.previous': ['Previous guided view', '上一个引导视图'],
|
||||
'viewer.guided.previous.title': ['Previous guided view ([)', '上一个引导视图([)'],
|
||||
'viewer.guided.next': ['Next guided view', '下一个引导视图'],
|
||||
'viewer.guided.next.title': ['Next guided view (])', '下一个引导视图(])'],
|
||||
'viewer.guided.views': ['Guided views', '引导视图'],
|
||||
'viewer.guided.explore': ['Explore this system', '探索此系统'],
|
||||
'viewer.guided.intro': ['Step through curated paths without changing the source diagram.', '沿精选路径逐步查看,而不改变源图表。'],
|
||||
'viewer.guided.trail': ['Story trail', '故事轨迹'],
|
||||
'viewer.guided.beat': ['Beat', '节点'],
|
||||
'viewer.guided.nextBeat': ['Next', '下一步'],
|
||||
'viewer.guided.play': ['Play guided story', '播放引导故事'],
|
||||
'viewer.guided.play.title': ['Play guided story (P)', '播放引导故事(P)'],
|
||||
'viewer.guided.pause': ['Pause guided story', '暂停引导故事'],
|
||||
'viewer.guided.pause.title': ['Pause guided story (P)', '暂停引导故事(P)'],
|
||||
'viewer.guided.replay': ['Replay guided story', '重播引导故事'],
|
||||
'viewer.guided.replay.title': ['Replay guided story (P)', '重播引导故事(P)'],
|
||||
'viewer.guided.playStory': ['Play story', '播放故事'],
|
||||
'viewer.guided.pauseStory': ['Pause', '暂停'],
|
||||
'viewer.guided.replayStory': ['Replay story', '重播故事'],
|
||||
'viewer.guided.motionUnavailable': ['Story playback unavailable while motion is Still', '静态模式下无法播放故事'],
|
||||
'viewer.guided.enableMotion': ['Switch motion to Live to play the guided story', '切换为动态模式以播放引导故事'],
|
||||
'viewer.guided.selectBeatLink': ['Select a Story Beat to copy its exact link', '选择故事节点以复制其精确链接'],
|
||||
'viewer.guided.copyMoment': ['Copy moment', '复制此刻'],
|
||||
'viewer.guided.momentCopied': ['Moment link copied', '已复制时刻链接'],
|
||||
'viewer.guided.momentCopyFailed': ['Could not copy story moment link', '无法复制故事时刻链接'],
|
||||
'viewer.guided.copied': ['Copied', '已复制'],
|
||||
'viewer.guided.copyFailed': ['Copy failed', '复制失败'],
|
||||
'viewer.guided.showAll': ['Show all', '显示全部'],
|
||||
'viewer.guided.showAll.aria': ['Show entire diagram', '显示完整图表'],
|
||||
'viewer.guided.chapters': ['Story chapters', '故事章节'],
|
||||
'viewer.guided.storyTrail': ['Story trail for {label}: {count} beats', '{label}的故事轨迹:{count} 个节点'],
|
||||
'viewer.guided.chapter.open': ['Open chapter {index} of {total}: {label}, {count} stops', '打开第 {index}/{total} 章:{label},{count} 个停靠点'],
|
||||
'viewer.guided.chapter.current': ['Current chapter {index} of {total}: {label}, {count} stops', '当前第 {index}/{total} 章:{label},{count} 个停靠点'],
|
||||
'viewer.guided.chapter.selectedNodes': ['{count} selected nodes', '已选择 {count} 个节点'],
|
||||
'viewer.guided.chapter.stops': ['{count} stops', '{count} 个停靠点'],
|
||||
'viewer.guided.chapter.stop.one': ['{count} stop', '{count} 个停靠点'],
|
||||
'viewer.guided.chapter.stop.other': ['{count} stops', '{count} 个停靠点'],
|
||||
'viewer.guided.chapter.current.title': ['{label} — current chapter, {count} stops', '{label} — 当前章节,{count} 个停靠点'],
|
||||
'viewer.guided.chapter.delta.expanded': ['{stay} stay, {enter} enter, {leave} leave', '{stay} 个保留,{enter} 个进入,{leave} 个离开'],
|
||||
'viewer.guided.chapter.delta.aria': ['Open chapter {index} of {total}: {label}. Chapter focus delta: {delta}', '打开第 {index}/{total} 章:{label}。章节聚焦差异:{delta}'],
|
||||
'viewer.guided.chapter.delta.title': ['{label} — {delta} chapter focus', '{label} — 章节聚焦 {delta}'],
|
||||
'viewer.guided.handoff': ['{from} → {to} · via {label}', '{from} → {to} · 经由{label}'],
|
||||
'viewer.guided.share.chapter': ['Chapter {index} / {total}', '章节 {index} / {total}'],
|
||||
'viewer.guided.share.initial': ['Chapter 01 / 01', '章节 01 / 01'],
|
||||
'viewer.guided.share.default': ['Guided chapter', '引导章节'],
|
||||
'viewer.guided.state.ready': ['Ready', '就绪'],
|
||||
'viewer.guided.state.playing': ['Playing', '播放中'],
|
||||
'viewer.guided.state.settled': ['Settled', '已完成'],
|
||||
'viewer.guided.state.paused': ['Paused', '已暂停'],
|
||||
'viewer.guided.state.pinned': ['Pinned', '已固定'],
|
||||
'viewer.guided.state.still': ['Still', '静态'],
|
||||
'viewer.guided.share.step': ['Step {index} / {total} · {label}', '步骤 {index} / {total} · {label}'],
|
||||
'viewer.guided.share.staticMoment': ['{step} · Static moment', '{step} · 静态时刻'],
|
||||
'viewer.guided.share.complete': ['{count} steps complete · {note}', '{count} 个步骤已完成 · {note}'],
|
||||
'viewer.guided.share.settled': ['Path settled for reading.', '路径已稳定,可供阅读。'],
|
||||
'viewer.guided.share.staticPath': ['{count} steps · Static path', '{count} 个步骤 · 静态路径'],
|
||||
'viewer.guided.share.ready': ['{count} steps · Ready', '{count} 个步骤 · 就绪'],
|
||||
'viewer.guided.share.aria': ['{state} chapter {index} of {total}: {label}. {beat}. {route}', '{state},第 {index}/{total} 章:{label}。{beat}。{route}'],
|
||||
'viewer.guided.beat.start': ['Beat {index} / {total} · {label} · starting point', '节点 {index} / {total} · {label} · 起点'],
|
||||
'viewer.guided.beat.forward': ['Beat {index} / {total} · {from} → {to}', '节点 {index} / {total} · {from} → {to}'],
|
||||
'viewer.guided.beat.reverse': ['Beat {index} / {total} · {from} → {to} · reverse authored link', '节点 {index} / {total} · {from} → {to} · 反向编写连接'],
|
||||
'viewer.guided.beat.multiple': ['Beat {index} / {total} · {from} ⇄ {to} · {count} authored links', '节点 {index} / {total} · {from} ⇄ {to} · {count} 条编写连接'],
|
||||
'viewer.guided.beat.group': ['Beat {index} / {total} · {from} · {to} · grouped · no direct link', '节点 {index} / {total} · {from} · {to} · 分组 · 无直接连接'],
|
||||
'viewer.guided.beat.aria.prefix': ['Story beat {index} of {total}: {label}. ', '故事节点 {index}/{total}:{label}。'],
|
||||
'viewer.guided.beat.aria.start': ['Starting point.', '起点。'],
|
||||
'viewer.guided.beat.aria.forward': ['From {from} through one authored forward relationship.', '从{from}经一条正向编写关系到达。'],
|
||||
'viewer.guided.beat.aria.reverse': ['From {from}; the authored relationship points from {to} to {from}.', '从{from}出发;编写关系实际由{to}指向{from}。'],
|
||||
'viewer.guided.beat.aria.multiple': ['From {from} through {count} authored relationships; shown without arbitrary motion.', '从{from}经 {count} 条编写关系到达;不使用任意动效。'],
|
||||
'viewer.guided.beat.aria.group': ['Grouped from {from} with no direct authored relationship.', '与{from}分组展示,没有直接编写关系。'],
|
||||
'viewer.guided.caption.start': ['Starting point', '起点'],
|
||||
'viewer.guided.caption.grouped': ['Grouped transition · no direct authored link', '分组过渡 · 无直接编写连接'],
|
||||
'viewer.guided.caption.more': [' +{count} more', ' +另外 {count} 条'],
|
||||
'viewer.guided.caption.reverse': ['Reverse authored relationship', '反向编写关系'],
|
||||
'viewer.guided.caption.relationships': ['{count} authored relationships', '{count} 条编写关系'],
|
||||
'viewer.guided.caption.relationship': ['Authored relationship', '编写关系'],
|
||||
'viewer.guided.caption.direction': ['authored direction: {from} → {to}', '编写方向:{from} → {to}'],
|
||||
'viewer.guided.caption.starting': ['Authored starting point', '编写起点'],
|
||||
'viewer.guided.beatLink': ['Copy link to current story moment: Beat {index} of {total}: {label}', '复制当前故事时刻链接:第 {index}/{total} 个节点:{label}'],
|
||||
'viewer.guided.noStory': ['This diagram has no authored guided story.', '此图表没有编写引导故事。'],
|
||||
|
||||
'viewer.guide.eyebrow': ['Diagram guide', '图表指南'],
|
||||
'viewer.guide.close': ['Close diagram guide', '关闭图表指南'],
|
||||
'viewer.guide.inspecting': ['Inspecting compiled semantics', '正在检查已编译语义'],
|
||||
'viewer.guide.actions': ['Diagram exploration actions', '图表探索操作'],
|
||||
'viewer.guide.find': ['Find any node', '查找任意节点'],
|
||||
'viewer.guide.find.hint': ['Search labels, responsibilities, kinds, and stable IDs.', '搜索标签、职责、类型和稳定 ID。'],
|
||||
'viewer.guide.route': ['Trace a route', '追踪路径'],
|
||||
'viewer.guide.route.aria': ['Trace a directed route', '追踪有向路径'],
|
||||
'viewer.guide.route.hint': ['Ask how two semantic nodes connect in authored direction.', '查看两个语义节点如何按编写方向连接。'],
|
||||
'viewer.guide.map': ['See the whole system', '查看完整系统'],
|
||||
'viewer.guide.map.hint': ['Open Semantic Radar with a live viewport and stable nodes.', '打开带实时视口和稳定节点的语义雷达。'],
|
||||
'viewer.guide.lens': ['Compare semantic kinds', '比较语义类型'],
|
||||
'viewer.guide.lens.hint': ['Count roles, reveal their traffic, and compare direct authored links.', '统计角色、显示流量并比较直接编写的连接。'],
|
||||
'viewer.guide.story': ['Play the guided story', '播放引导故事'],
|
||||
'viewer.guide.story.hint': ['Walk the authored chapters and real relationships.', '浏览已编写的章节和真实关系。'],
|
||||
'viewer.guide.present': ['Enter Presentation Stage', '进入演示模式'],
|
||||
'viewer.guide.present.hint': ['Give the live diagram the viewport without changing export.', '让实时图表占满视口,同时不改变导出。'],
|
||||
'viewer.guide.shortcuts': ['Additional keyboard shortcuts', '其他键盘快捷键'],
|
||||
'viewer.guide.shortcut.export': ['Export', '导出'],
|
||||
'viewer.guide.shortcut.theme': ['Theme', '主题'],
|
||||
'viewer.guide.shortcut.style': ['Style', '风格'],
|
||||
'viewer.guide.shortcut.reset': ['Reset', '重置'],
|
||||
'viewer.guide.shortcut.zoomIn': ['Zoom in', '放大'],
|
||||
'viewer.guide.shortcut.zoomOut': ['Zoom out', '缩小'],
|
||||
'viewer.guide.shortcut.close': ['Close', '关闭'],
|
||||
'viewer.guide.facts': ['{nodes} · {relationships} · {views}', '{nodes} · {relationships} · {views}'],
|
||||
'viewer.guide.fact.node.one': ['{count} semantic node', '{count} 个语义节点'],
|
||||
'viewer.guide.fact.node.other': ['{count} semantic nodes', '{count} 个语义节点'],
|
||||
'viewer.guide.fact.relationship.one': ['{count} relationship', '{count} 条关系'],
|
||||
'viewer.guide.fact.relationship.other': ['{count} relationships', '{count} 条关系'],
|
||||
'viewer.guide.fact.view.one': ['{count} guided view', '{count} 个引导视图'],
|
||||
'viewer.guide.fact.view.other': ['{count} guided views', '{count} 个引导视图'],
|
||||
'viewer.guide.story.available.one': ['Walk {count} authored chapter and its real relationships.', '浏览 {count} 个已编写章节及其真实关系。'],
|
||||
'viewer.guide.story.available.other': ['Walk {count} authored chapters and their real relationships.', '浏览 {count} 个已编写章节及其真实关系。'],
|
||||
'viewer.guide.story.unavailable': ['No authored guided story in this diagram.', '此图表没有编写引导故事。'],
|
||||
'viewer.guide.open': ['Open diagram guide', '打开图表指南'],
|
||||
'viewer.guide.noStory': ['This diagram has no authored guided story.', '此图表没有编写引导故事。'],
|
||||
|
||||
'viewer.finder.title': ['Find a node', '查找节点'],
|
||||
'viewer.finder.close': ['Close node finder', '关闭节点查找器'],
|
||||
'viewer.finder.placeholder': ['Search labels or IDs', '搜索标签或 ID'],
|
||||
'viewer.finder.search': ['Search diagram nodes', '搜索图表节点'],
|
||||
'viewer.finder.results': ['Diagram nodes', '图表节点'],
|
||||
'viewer.finder.empty': ['No matching nodes', '没有匹配的节点'],
|
||||
'viewer.finder.result.focus': ['Focus {label}', '聚焦{label}'],
|
||||
'viewer.finder.result.routeStart': ['Choose {label} as route start', '选择{label}作为路径起点'],
|
||||
'viewer.finder.result.routeTarget': ['Choose {label} as route destination, {links}', '选择{label}作为路径终点,{links}'],
|
||||
'viewer.finder.status.empty': ['No matching nodes', '没有匹配的节点'],
|
||||
'viewer.finder.status.count.one': ['{count} matching node', '{count} 个匹配节点'],
|
||||
'viewer.finder.status.count.other': ['{count} matching nodes', '{count} 个匹配节点'],
|
||||
'viewer.finder.noun.nodes': ['nodes', '个节点'],
|
||||
'viewer.finder.link.one': ['{count} link', '{count} 条连接'],
|
||||
'viewer.finder.link.other': ['{count} links', '{count} 条连接'],
|
||||
'viewer.finder.result.focus.one': ['Focus {label}, {count} related connection', '聚焦{label},{count} 条相关连接'],
|
||||
'viewer.finder.result.focus.other': ['Focus {label}, {count} related connections', '聚焦{label},{count} 条相关连接'],
|
||||
'viewer.finder.status.filtered': ['{visible} of {available} {noun}', '{visible}/{available} {noun}'],
|
||||
'viewer.finder.status.all': ['{available} {noun}', '{available} {noun}'],
|
||||
|
||||
'viewer.passport.eyebrow': ['Semantic passport', '语义护照'],
|
||||
'viewer.passport.metadata': ['Node metadata', '节点元数据'],
|
||||
'viewer.passport.evidence': ['Verified source evidence', '已验证的源代码证据'],
|
||||
'viewer.passport.verified': ['Verified source', '已验证来源'],
|
||||
'viewer.passport.reach': ['Authored reach', '编写可达范围'],
|
||||
'viewer.passport.reach.trace': ['Trace authored reachability', '追踪编写的可达性'],
|
||||
'viewer.passport.upstream': ['Upstream', '上游'],
|
||||
'viewer.passport.downstream': ['Downstream', '下游'],
|
||||
'viewer.passport.upstream.trace': ['Trace upstream authored reachability', '追踪上游编写可达性'],
|
||||
'viewer.passport.downstream.trace': ['Trace downstream authored reachability', '追踪下游编写可达性'],
|
||||
'viewer.passport.close': ['Close semantic passport', '关闭语义护照'],
|
||||
'viewer.passport.copy': ['Copy link', '复制链接'],
|
||||
'viewer.passport.copy.focus': ['Copy link to focused node', '复制聚焦节点的链接'],
|
||||
'viewer.passport.relations': ['Relations', '关系'],
|
||||
'viewer.passport.relations.show': ['Show connected relationships', '显示关联关系'],
|
||||
'viewer.passport.relations.hide': ['Hide connected relationships', '隐藏关联关系'],
|
||||
'viewer.passport.relations.list': ['Connected relationships', '关联关系'],
|
||||
'viewer.passport.copyRelation': ['Copy relation', '复制关系'],
|
||||
'viewer.passport.copyNode': ['Copy node', '复制节点'],
|
||||
'viewer.passport.copyPinned': ['Copy link to pinned relationship', '复制固定关系的链接'],
|
||||
'viewer.passport.copySource': ['Copy link to source node', '复制来源节点的链接'],
|
||||
'viewer.passport.copy.focused.success': ['Focused node link copied', '已复制聚焦节点链接'],
|
||||
'viewer.passport.copy.pinned.success': ['Pinned relationship link copied', '已复制固定关系链接'],
|
||||
'viewer.passport.copy.focused.failed': ['Could not copy focused node link', '无法复制聚焦节点链接'],
|
||||
'viewer.passport.copy.pinned.failed': ['Could not copy pinned relationship link', '无法复制固定关系链接'],
|
||||
'viewer.passport.relationship.none': ['No connected relationships', '没有关联关系'],
|
||||
'viewer.passport.relationship.count.one': ['{count} relation', '{count} 条关系'],
|
||||
'viewer.passport.relationship.count.other': ['{count} relations', '{count} 条关系'],
|
||||
'viewer.passport.relationship.show.one': ['Show {count} connected relationship', '显示 {count} 条关联关系'],
|
||||
'viewer.passport.relationship.show.other': ['Show {count} connected relationships', '显示 {count} 条关联关系'],
|
||||
'viewer.passport.relationship.summary': ['{out} outgoing · {in} incoming{loops}', '{out} 条出向 · {in} 条入向{loops}'],
|
||||
'viewer.passport.relationship.loops': [' · {count} loop', ' · {count} 条自环'],
|
||||
'viewer.passport.relationship.explorer': ['Direct relationship explorer', '直接关系浏览器'],
|
||||
'viewer.passport.relationship.help': ['Use arrow keys to explore relationships. Press Enter or Space to pin details; Escape clears.', '使用方向键浏览关系。按 Enter 或空格键固定详情;按 Escape 清除。'],
|
||||
'viewer.passport.relationship.loopsBack': ['loops back', '回环'],
|
||||
'viewer.passport.relationship.connectsTo': ['connects to', '连接到'],
|
||||
'viewer.passport.relationship.connectsFrom': ['connects from', '连接自'],
|
||||
'viewer.passport.relationship.pinned': ['Pinned relationship · {from} → {to} · {label}', '已固定关系 · {from} → {to} · {label}'],
|
||||
'viewer.passport.relationship.inspect': ['Inspect relationship {index} of {total}: {from} to {to}, {label}. Press Enter for details.', '检查第 {index}/{total} 条关系:{from} 到 {to},{label}。按 Enter 查看详情。'],
|
||||
'viewer.passport.relationship.group.out': ['Outgoing', '出向'],
|
||||
'viewer.passport.relationship.group.in': ['Incoming', '入向'],
|
||||
'viewer.passport.relationship.group.loop': ['Self loops', '自环'],
|
||||
'viewer.passport.relationship.row': ['{group}: {relationship}, {neighbor}', '{group}:{relationship},{neighbor}'],
|
||||
'viewer.passport.relationship.direction.out': ['OUT →', '出 →'],
|
||||
'viewer.passport.relationship.direction.in': ['← IN', '← 入'],
|
||||
'viewer.passport.relationship.direction.loop': ['LOOP', '自环'],
|
||||
'viewer.passport.sourceCount.one': ['{count} verified source reference', '{count} 个已验证来源引用'],
|
||||
'viewer.passport.sourceCount.other': ['{count} verified source references', '{count} 个已验证来源引用'],
|
||||
'viewer.passport.sourceMarker': ['SRC', '来源'],
|
||||
'viewer.passport.beacon.one': ['{count} verified source; focus this node to inspect', '{count} 个已验证来源;聚焦此节点以检查'],
|
||||
'viewer.passport.beacon.other': ['{count} verified sources; focus this node to inspect', '{count} 个已验证来源;聚焦此节点以检查'],
|
||||
'viewer.passport.repository.open': ['Open verified repository revision {revision}', '打开已验证的仓库修订版本 {revision}'],
|
||||
'viewer.passport.source.open': ['Open verified source {path} at revision {revision}', '打开修订版本 {revision} 中已验证的来源 {path}'],
|
||||
'viewer.passport.source.openLink': ['Open ↗', '打开 ↗'],
|
||||
'viewer.passport.reach.upstream.one': ['Trace {count} upstream authored node', '追踪 {count} 个上游编写节点'],
|
||||
'viewer.passport.reach.upstream.other': ['Trace {count} upstream authored nodes', '追踪 {count} 个上游编写节点'],
|
||||
'viewer.passport.reach.downstream.one': ['Trace {count} downstream authored node', '追踪 {count} 个下游编写节点'],
|
||||
'viewer.passport.reach.downstream.other': ['Trace {count} downstream authored nodes', '追踪 {count} 个下游编写节点'],
|
||||
'viewer.passport.reach.noUpstream': ['No upstream authored nodes', '没有上游编写节点'],
|
||||
'viewer.passport.reach.noDownstream': ['No downstream authored nodes', '没有下游编写节点'],
|
||||
'viewer.passport.reach.status': ['{direction} · {nodes} nodes · {links} links · max {hops} hops', '{direction} · {nodes} 个节点 · {links} 条连接 · 最深 {hops} 跳'],
|
||||
|
||||
'viewer.route.eyebrow': ['Route probe', '路径探测'],
|
||||
'viewer.route.start': ['Choose a start node', '选择起点节点'],
|
||||
'viewer.route.start.find': ['Find start', '查找起点'],
|
||||
'viewer.route.start.find.aria': ['Find a route start', '查找路径起点'],
|
||||
'viewer.route.copy': ['Copy link', '复制链接'],
|
||||
'viewer.route.copy.aria': ['Copy link to traced route', '复制已追踪路径的链接'],
|
||||
'viewer.route.clear': ['Clear', '清除'],
|
||||
'viewer.route.clear.aria': ['Clear route probe', '清除路径探测'],
|
||||
'viewer.route.traced': ['Traced route', '已追踪路径'],
|
||||
'viewer.route.pickTwo': ['Pick two semantic nodes on the diagram', '在图表中选择两个语义节点'],
|
||||
'viewer.route.pickOne': ['Pick a semantic node on the diagram', '在图表中选择一个语义节点'],
|
||||
'viewer.route.controls': ['Route journey controls', '路径旅程控制'],
|
||||
'viewer.route.previous': ['Previous route position', '上一个路径位置'],
|
||||
'viewer.route.play': ['Play route journey', '播放路径旅程'],
|
||||
'viewer.route.pause': ['Pause route journey', '暂停路径旅程'],
|
||||
'viewer.route.replay': ['Replay route journey', '重播路径旅程'],
|
||||
'viewer.route.next': ['Next route position', '下一个路径位置'],
|
||||
'viewer.route.journey': ['Journey', '旅程'],
|
||||
'viewer.route.pause.label': ['Pause', '暂停'],
|
||||
'viewer.route.replay.label': ['Replay', '重播'],
|
||||
'viewer.route.overview': ['Overview', '总览'],
|
||||
'viewer.route.overview.aria': ['Show complete route overview', '显示完整路径总览'],
|
||||
'viewer.route.instructions': ['Choose the source, then the destination. Direction matters.', '先选择来源,再选择目标;方向很重要。'],
|
||||
'viewer.route.destination': ['Choose a destination from {label}', '选择从{label}出发的目标'],
|
||||
'viewer.route.destination.find': ['Find target', '查找目标'],
|
||||
'viewer.route.destination.find.aria': ['Find a reachable route destination', '查找可达的路径目标'],
|
||||
'viewer.route.differentDestination': ['Choose a different destination', '选择其他目标'],
|
||||
'viewer.route.distinct': ['A route needs two distinct semantic nodes.', '一条路径需要两个不同的语义节点。'],
|
||||
'viewer.route.unreachable': ['No directed route to {label}', '没有通往{label}的有向路径'],
|
||||
'viewer.route.unreachable.detail': ['{target} is not reachable from {source}. Pick a highlighted destination.', '从{source}无法到达{target}。请选择高亮的目标。'],
|
||||
'viewer.route.start.instructions': ['Select the source. The next step will reveal only directed destinations.', '选择来源。下一步只会显示有向可达的目标。'],
|
||||
'viewer.route.copy.success': ['Traced route link copied', '已复制路径链接'],
|
||||
'viewer.route.copy.failed': ['Could not copy traced route link', '无法复制路径链接'],
|
||||
'viewer.route.position': ['Route position {index} of {total}: {label}', '路径位置 {index}/{total}:{label}'],
|
||||
'viewer.route.step': ['Step {index} of {total} · {phase} · {label}', '第 {index}/{total} 步 · {phase} · {label}'],
|
||||
'viewer.route.motionRequired': ['Automatic journey requires Live motion', '自动旅程需要动态模式'],
|
||||
'viewer.route.trigger.clear': ['Clear traced route', '清除已追踪路径'],
|
||||
'viewer.route.overview.status': ['{nodes} · {hops} · shortest authored route', '{nodes} · {hops} · 最短编写路径'],
|
||||
'viewer.route.overview.node.one': ['{count} node', '{count} 个节点'],
|
||||
'viewer.route.overview.node.other': ['{count} nodes', '{count} 个节点'],
|
||||
'viewer.route.overview.hop.one': ['{count} directed hop', '{count} 个有向跳转'],
|
||||
'viewer.route.overview.hop.other': ['{count} directed hops', '{count} 个有向跳转'],
|
||||
'viewer.route.phase.playing': ['Playing', '播放中'],
|
||||
'viewer.route.phase.complete': ['Complete', '已完成'],
|
||||
'viewer.route.phase.inspecting': ['Inspecting', '检查中'],
|
||||
'viewer.route.destination.count.one': ['{count} directed destination available. Pick a highlighted node.', '有 {count} 个有向目标可用。请选择高亮节点。'],
|
||||
'viewer.route.destination.count.other': ['{count} directed destinations available. Pick a highlighted node.', '有 {count} 个有向目标可用。请选择高亮节点。'],
|
||||
'viewer.route.noOutgoing': ['No outgoing route starts here. Clear and choose another source.', '此处没有可用的出向路径。请清除后选择其他来源。'],
|
||||
'viewer.route.result.title': ['{source} to {target}', '{source} 到 {target}'],
|
||||
'viewer.route.finder.source.title': ['Choose route start', '选择路径起点'],
|
||||
'viewer.route.finder.source.placeholder': ['Search route sources', '搜索路径来源'],
|
||||
'viewer.route.finder.source.empty': ['No matching route sources', '没有匹配的路径来源'],
|
||||
'viewer.route.finder.source.results': ['Nodes that can start a route', '可作为路径起点的节点'],
|
||||
'viewer.route.finder.source.noun': ['route sources', '个路径来源'],
|
||||
'viewer.route.finder.source.badge': ['start', '起点'],
|
||||
'viewer.route.finder.target.title': ['Destination from {label}', '从{label}出发的目标'],
|
||||
'viewer.route.finder.target.placeholder': ['Search reachable destinations', '搜索可达目标'],
|
||||
'viewer.route.finder.target.empty': ['No matching reachable destinations', '没有匹配的可达目标'],
|
||||
'viewer.route.finder.target.results': ['Reachable route destinations', '可达路径目标'],
|
||||
'viewer.route.finder.target.noun': ['reachable destinations', '个可达目标'],
|
||||
'viewer.route.hop.one': ['{count} hop', '{count} 跳'],
|
||||
'viewer.route.hop.other': ['{count} hops', '{count} 跳'],
|
||||
|
||||
'viewer.lens.eyebrow': ['Semantic lens', '语义透镜'],
|
||||
'viewer.lens.title': ['Compare system roles', '比较系统角色'],
|
||||
'viewer.lens.close': ['Close semantic lens', '关闭语义透镜'],
|
||||
'viewer.lens.instruction': ['Choose up to two semantic kinds. One reveals its real traffic; two compare only direct authored relationships.', '最多选择两种语义类型。选择一种可显示其真实流量;选择两种只比较直接编写的关系。'],
|
||||
'viewer.lens.kinds': ['Semantic kinds', '语义类型'],
|
||||
'viewer.lens.choose': ['Choose a kind to inspect its nodes and touching relationships.', '选择一种类型以检查其节点和相连关系。'],
|
||||
'viewer.lens.copy': ['Copy link to semantic lens', '复制语义透镜链接'],
|
||||
'viewer.lens.clear': ['Clear semantic lens', '清除语义透镜'],
|
||||
'viewer.lens.open': ['Open semantic lens', '打开语义透镜'],
|
||||
'viewer.lens.openActive': ['Open active semantic lens', '打开当前语义透镜'],
|
||||
'viewer.lens.legend': ['Semantic legend', '语义图例'],
|
||||
'viewer.lens.legend.inspect.one': ['Inspect {label}, {count} node', '检查{label},{count} 个节点'],
|
||||
'viewer.lens.legend.inspect.other': ['Inspect {label}, {count} nodes', '检查{label},{count} 个节点'],
|
||||
'viewer.lens.kind.count.one': ['{label}, {count} node', '{label},{count} 个节点'],
|
||||
'viewer.lens.kind.count.other': ['{label}, {count} nodes', '{label},{count} 个节点'],
|
||||
'viewer.lens.compare.one': ['{first} → {second}: {forward} · {second} → {first}: {reverse} · {count} direct relationship', '{first} → {second}:{forward} · {second} → {first}:{reverse} · 共 {count} 条直接关系'],
|
||||
'viewer.lens.compare.other': ['{first} → {second}: {forward} · {second} → {first}: {reverse} · {count} direct relationships', '{first} → {second}:{forward} · {second} → {first}:{reverse} · 共 {count} 条直接关系'],
|
||||
'viewer.lens.single': ['{nodes} · {relationships} · connected peers remain visible', '{nodes} · {relationships} · 已连接节点保持可见'],
|
||||
'viewer.lens.node.one': ['{count} {label} node', '{count} 个{label}节点'],
|
||||
'viewer.lens.node.other': ['{count} {label} nodes', '{count} 个{label}节点'],
|
||||
'viewer.lens.relationship.one': ['{count} touching relationship', '{count} 条相连关系'],
|
||||
'viewer.lens.relationship.other': ['{count} touching relationships', '{count} 条相连关系'],
|
||||
|
||||
'viewer.radar.title': ['Semantic radar', '语义雷达'],
|
||||
'viewer.radar.building': ['Building overview', '正在构建总览'],
|
||||
'viewer.radar.openFull': ['Open full semantic radar', '打开完整语义雷达'],
|
||||
'viewer.radar.open': ['Open radar', '打开雷达'],
|
||||
'viewer.radar.close': ['Close semantic radar', '关闭语义雷达'],
|
||||
'viewer.radar.surface': ['Diagram overview. Click a node to focus it, or use arrow keys to pan.', '图表总览。点击节点进行聚焦,或使用方向键平移。'],
|
||||
'viewer.radar.click': ['Click node', '点击节点'],
|
||||
'viewer.radar.drag': ['Drag to pan', '拖动平移'],
|
||||
'viewer.radar.space': ['Semantic radar needs more MAP space.', '语义雷达需要更多地图可见空间。'],
|
||||
'viewer.radar.nodes': ['Semantic diagram radar nodes', '语义图表雷达节点'],
|
||||
'viewer.radar.focus': ['Focus {label} from Semantic Radar', '从语义雷达聚焦{label}'],
|
||||
'viewer.radar.status': ['{count} nodes · {viewport}', '{count} 个节点 · {viewport}'],
|
||||
'viewer.radar.fullMap': ['{count} nodes · full map', '{count} 个节点 · 完整地图'],
|
||||
'viewer.radar.compacted': ['Radar compacted to avoid covering the Semantic Passport or MAP controls.', '已收紧雷达,避免遮挡语义护照或地图控件。'],
|
||||
'viewer.radar.cancelWaiting': ['Cancel semantic radar waiting for more MAP space', '取消等待更多地图空间的语义雷达'],
|
||||
'viewer.radar.needsSpace': ['Semantic radar needs more visible MAP space', '语义雷达需要更多可见地图空间'],
|
||||
'viewer.radar.viewport.full': ['full map', '完整地图'],
|
||||
'viewer.radar.viewport.width': ['{percent}% width', '宽度 {percent}%'],
|
||||
'viewer.radar.viewport.scale': ['{percent}% viewport', '视口 {percent}%'],
|
||||
|
||||
'viewer.nav.controls': ['Diagram view controls', '图表视图控制'],
|
||||
'viewer.nav.route': ['Trace a directed route', '追踪有向路径'],
|
||||
'viewer.nav.route.title': ['Trace route (R)', '追踪路径(R)'],
|
||||
'viewer.nav.route.short': ['PATH', '路径'],
|
||||
'viewer.nav.radar': ['Open semantic radar', '打开语义雷达'],
|
||||
'viewer.nav.radar.title': ['Semantic radar (M)', '语义雷达(M)'],
|
||||
'viewer.nav.radar.short': ['MAP', '地图'],
|
||||
'viewer.nav.lens': ['Open semantic lens', '打开语义透镜'],
|
||||
'viewer.nav.lens.title': ['Semantic lens (L)', '语义透镜(L)'],
|
||||
'viewer.nav.lens.short': ['LENS', '透镜'],
|
||||
'viewer.nav.find': ['Find a node', '查找节点'],
|
||||
'viewer.nav.find.title': ['Find a node (/)', '查找节点(/)'],
|
||||
'viewer.nav.guide': ['Open diagram guide', '打开图表指南'],
|
||||
'viewer.nav.guide.title': ['Diagram guide (?)', '图表指南(?)'],
|
||||
'viewer.nav.zoomOut': ['Zoom out', '缩小'],
|
||||
'viewer.nav.zoomOut.title': ['Zoom out (-)', '缩小(-)'],
|
||||
'viewer.nav.reset': ['Reset diagram view', '重置图表视图'],
|
||||
'viewer.nav.reset.title': ['Reset view (0)', '重置视图(0)'],
|
||||
'viewer.nav.read': ['READ', '阅读'],
|
||||
'viewer.nav.zoomIn': ['Zoom in', '放大'],
|
||||
'viewer.nav.zoomIn.title': ['Zoom in (+)', '放大(+)'],
|
||||
'viewer.nav.camera': ['{hint}. Reset diagram view', '{hint}。重置图表视图'],
|
||||
'viewer.nav.camera.title': ['{semantic}{hint} · reset view (0)', '{semantic}{hint} · 重置视图(0)'],
|
||||
'viewer.nav.camera.semantic': ['Semantic camera active · ', '语义相机已启用 · '],
|
||||
'viewer.nav.level.map': ['MAP', '概览'],
|
||||
'viewer.nav.level.read': ['READ', '阅读'],
|
||||
'viewer.nav.level.full': ['FULL', '完整'],
|
||||
'viewer.nav.level.auto': ['AUTO', '自动'],
|
||||
'viewer.nav.detail.map': ['Zoom in to reveal relationship labels and node context', '放大以显示关系标签和节点上下文'],
|
||||
'viewer.nav.detail.read': ['Zoom in again to reveal tags and annotations', '再次放大以显示标签和注释'],
|
||||
'viewer.nav.detail.full': ['Full diagram detail', '完整图表详情'],
|
||||
|
||||
'viewer.intent.summary': ['{label}. {out} outgoing, {in} incoming{loops}. {total} connections. Press Enter for details.', '{label}。{out} 条出向,{in} 条入向{loops}。共 {total} 条连接。按 Enter 查看详情。'],
|
||||
'viewer.intent.loops': [', {count} self loop', ',{count} 条自环'],
|
||||
|
||||
'viewer.common.copied': ['Copied', '已复制'],
|
||||
'viewer.common.copyFailed': ['Copy failed', '复制失败'],
|
||||
'viewer.common.copyLink': ['Copy link', '复制链接'],
|
||||
'viewer.common.clear': ['Clear', '清除'],
|
||||
'viewer.common.close': ['Close', '关闭'],
|
||||
};
|
||||
|
||||
for (const [key, messages] of Object.entries(MESSAGE_PAIRS)) {
|
||||
if (messages.length !== SUPPORTED_LOCALES.length || messages.some((message) => typeof message !== 'string')) {
|
||||
throw new Error(`Incomplete Archify i18n tuple ${JSON.stringify(key)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const CATALOGS = Object.fromEntries(SUPPORTED_LOCALES.map((locale, index) => [
|
||||
locale,
|
||||
Object.fromEntries(Object.entries(MESSAGE_PAIRS).map(([key, pair]) => [key, pair[index]])),
|
||||
]));
|
||||
|
||||
export function resolveLocale(locale) {
|
||||
return SUPPORTED_LOCALES.includes(locale) ? locale : DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export function formatMessage(template, values = {}) {
|
||||
return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => (
|
||||
Object.hasOwn(values, key) ? String(values[key]) : match
|
||||
));
|
||||
}
|
||||
|
||||
export function translateMessage(locale, key, values = {}) {
|
||||
const resolved = resolveLocale(locale);
|
||||
if (!Object.hasOwn(CATALOGS[resolved], key)) {
|
||||
throw new Error(`Missing Archify i18n message ${JSON.stringify(key)} for ${resolved}`);
|
||||
}
|
||||
return formatMessage(CATALOGS[resolved][key], values);
|
||||
}
|
||||
|
||||
export function translateCount(locale, key, count, values = {}) {
|
||||
const suffix = count === 1 ? 'one' : 'other';
|
||||
return translateMessage(locale, `${key}.${suffix}`, { ...values, count });
|
||||
}
|
||||
|
||||
export function viewerCatalog(locale) {
|
||||
const resolved = resolveLocale(locale);
|
||||
return Object.fromEntries(Object.entries(CATALOGS[resolved]).filter(([key]) => key.startsWith('viewer.')));
|
||||
}
|
||||
|
||||
export function localizeTemplate(template, locale) {
|
||||
return template.replace(/\{\{i18n:([a-zA-Z0-9_.-]+)\}\}/g, (_match, key) => escapeHtml(translateMessage(locale, key)));
|
||||
}
|
||||
|
||||
export function catalogKeys() {
|
||||
return Object.keys(MESSAGE_PAIRS);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/** Serialize computed layout for dry-run / inspect (#9). */
|
||||
|
||||
export function componentBox(c) {
|
||||
return {
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
label: c.label,
|
||||
x: Math.round(c.x),
|
||||
y: Math.round(c.y),
|
||||
width: c.width,
|
||||
height: c.height,
|
||||
...(Number.isInteger(c.row) ? { row: c.row } : {}),
|
||||
...(Number.isInteger(c.col) ? { col: c.col } : {}),
|
||||
...(Array.isArray(c.pos) ? { pos: c.pos.map(Math.round) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function boundaryBox(b) {
|
||||
return {
|
||||
kind: b.kind,
|
||||
label: b.label,
|
||||
x: Math.round(b.x),
|
||||
y: Math.round(b.y),
|
||||
width: Math.round(b.width),
|
||||
height: Math.round(b.height),
|
||||
wraps: b.wraps,
|
||||
};
|
||||
}
|
||||
|
||||
export function connectionPath(conn, routed, labelAt) {
|
||||
return {
|
||||
from: conn.from,
|
||||
to: conn.to,
|
||||
label: conn.label ?? null,
|
||||
variant: conn.variant ?? 'default',
|
||||
route: conn.route ?? 'auto',
|
||||
points: routed.points.map(([x, y]) => [Math.round(x), Math.round(y)]),
|
||||
...(labelAt ? { labelAt: labelAt.map(Math.round) } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { throwDiagnosticError } from './diagnostics.mjs';
|
||||
import { rectsOverlap, segmentIntersectsRect } from './geometry.mjs';
|
||||
import { esc, textUnits } from './utils.mjs';
|
||||
import { translateMessage } from './i18n.mjs';
|
||||
|
||||
const DEFAULT_FONT_SIZE = 8;
|
||||
const DEFAULT_ITEM_GAP = 22;
|
||||
const DEFAULT_LINE_GAP = 22;
|
||||
const DEFAULT_SWATCH_GAP = 8;
|
||||
const TEXT_ADVANCE_EM = 0.62;
|
||||
const INTERACTIVE_BADGE_ALLOWANCE = 21;
|
||||
|
||||
export function relationshipLegendObstacles(relations, { pointsFor, labelRectFor } = {}) {
|
||||
const obstacles = [];
|
||||
for (const [index, relation] of (Array.isArray(relations) ? relations : []).entries()) {
|
||||
const points = typeof pointsFor === 'function' ? pointsFor(relation, index) : [];
|
||||
const finitePoints = (Array.isArray(points) ? points : []).filter((point) => (
|
||||
Array.isArray(point) && point.length === 2 && point.every(Number.isFinite)
|
||||
));
|
||||
for (let pointIndex = 0; pointIndex < finitePoints.length - 1; pointIndex += 1) {
|
||||
obstacles.push({
|
||||
kind: 'relationship-segment',
|
||||
start: finitePoints[pointIndex],
|
||||
end: finitePoints[pointIndex + 1],
|
||||
});
|
||||
}
|
||||
const labelRect = typeof labelRectFor === 'function' ? labelRectFor(relation, index) : null;
|
||||
if (labelRect && [labelRect.x, labelRect.y, labelRect.width, labelRect.height].every(Number.isFinite)) {
|
||||
obstacles.push({ kind: 'relationship-label', ...labelRect });
|
||||
}
|
||||
}
|
||||
return obstacles;
|
||||
}
|
||||
|
||||
export function resolveLegend(config, catalog, presentKinds) {
|
||||
const mode = config?.mode || 'auto';
|
||||
if (mode === 'hidden') return [];
|
||||
const present = presentKinds instanceof Set ? presentKinds : new Set(presentKinds || []);
|
||||
const overrides = config?.entries || {};
|
||||
|
||||
return catalog.flatMap((catalogEntry) => {
|
||||
const override = overrides[catalogEntry.kind] || {};
|
||||
const selectedByMode = mode === 'all' || present.has(catalogEntry.kind);
|
||||
const visible = override.visible === true || (selectedByMode && override.visible !== false);
|
||||
if (!visible) return [];
|
||||
return [{
|
||||
...catalogEntry,
|
||||
label: override.label || catalogEntry.label,
|
||||
present: present.has(catalogEntry.kind),
|
||||
interactive: catalogEntry.interactive !== false && present.has(catalogEntry.kind),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function measuredEntryWidth(entry, fontSize, swatchGap) {
|
||||
const swatchWidth = entry.swatchWidth ?? 14;
|
||||
return Math.ceil(
|
||||
swatchWidth
|
||||
+ swatchGap
|
||||
+ textUnits(entry.label) * fontSize * TEXT_ADVANCE_EM
|
||||
+ (entry.interactive ? INTERACTIVE_BADGE_ALLOWANCE : 0),
|
||||
);
|
||||
}
|
||||
|
||||
// One pure footprint calculation owns both auto-viewBox sizing and final SVG
|
||||
// placement. Callers must not maintain a second approximation of legend width
|
||||
// or row count; that would make generated geometry disagree with validation.
|
||||
export function legendFootprint(entries, {
|
||||
width,
|
||||
fontSize = DEFAULT_FONT_SIZE,
|
||||
itemGap = DEFAULT_ITEM_GAP,
|
||||
lineGap = DEFAULT_LINE_GAP,
|
||||
swatchGap = DEFAULT_SWATCH_GAP,
|
||||
} = {}) {
|
||||
if (!entries.length) {
|
||||
return { measured: [], rows: [], rowCount: 0, minWidth: 0, extraHeight: 0 };
|
||||
}
|
||||
const measured = entries.map((entry) => ({
|
||||
...entry,
|
||||
width: measuredEntryWidth(entry, fontSize, entry.swatchGap ?? swatchGap),
|
||||
}));
|
||||
const rows = [[]];
|
||||
let cursor = 0;
|
||||
for (const entry of measured) {
|
||||
const row = rows.at(-1);
|
||||
const required = (row.length ? itemGap : 0) + entry.width;
|
||||
if (row.length && cursor + required > width) {
|
||||
rows.push([entry]);
|
||||
cursor = entry.width;
|
||||
} else {
|
||||
row.push(entry);
|
||||
cursor += required;
|
||||
}
|
||||
}
|
||||
return {
|
||||
measured,
|
||||
rows,
|
||||
rowCount: rows.length,
|
||||
minWidth: Math.max(...measured.map((entry) => entry.width)),
|
||||
extraHeight: (rows.length - 1) * lineGap,
|
||||
};
|
||||
}
|
||||
|
||||
export function measureLegend(entries, {
|
||||
x,
|
||||
baselineY,
|
||||
width,
|
||||
fontSize = DEFAULT_FONT_SIZE,
|
||||
itemGap = DEFAULT_ITEM_GAP,
|
||||
lineGap = DEFAULT_LINE_GAP,
|
||||
swatchGap = DEFAULT_SWATCH_GAP,
|
||||
minTitleY = 0,
|
||||
obstacles = [],
|
||||
unfit = 'error',
|
||||
diagramType = 'diagram',
|
||||
} = {}) {
|
||||
if (!entries.length) return { entries: [], rowCount: 0, titleY: null };
|
||||
const footprint = legendFootprint(entries, { width, fontSize, itemGap, lineGap, swatchGap });
|
||||
const tooWide = footprint.measured.find((entry) => entry.width > width);
|
||||
if (tooWide) {
|
||||
if (unfit === 'hide') return null;
|
||||
const message = `[legend/label-too-wide] ${diagramType} legend label for "${tooWide.kind}" needs ${tooWide.width}px but only ${width}px is available.`;
|
||||
throwDiagnosticError(message, [{
|
||||
code: 'legend/label-too-wide',
|
||||
severity: 'error',
|
||||
message,
|
||||
subject: { diagramType, path: `/meta/legend/entries/${tooWide.kind}/label` },
|
||||
evidence: { kind: tooWide.kind, measuredWidthPx: tooWide.width, availableWidthPx: width },
|
||||
supportedFixes: ['shorten the legend label or use a wider viewBox'],
|
||||
}]);
|
||||
}
|
||||
|
||||
const titleY = baselineY - footprint.extraHeight - 20;
|
||||
const legendTopY = titleY - 10;
|
||||
if (legendTopY < minTitleY) {
|
||||
if (unfit === 'hide') return null;
|
||||
const message = `[legend/vertical-overflow] ${diagramType} legend needs ${footprint.rowCount} rows, which would start at y=${legendTopY} above the available legend band at y=${minTitleY}.`;
|
||||
throwDiagnosticError(message, [{
|
||||
code: 'legend/vertical-overflow',
|
||||
severity: 'error',
|
||||
message,
|
||||
subject: { diagramType, path: '/meta/legend' },
|
||||
evidence: { rowCount: footprint.rowCount, requiredTopY: legendTopY, availableTopY: minTitleY },
|
||||
supportedFixes: ['shorten legend labels, hide nonessential entries, or use a wider viewBox'],
|
||||
}]);
|
||||
}
|
||||
|
||||
const positioned = [];
|
||||
footprint.rows.forEach((row, rowIndex) => {
|
||||
let entryX = x;
|
||||
const baseline = baselineY - (footprint.rowCount - rowIndex - 1) * lineGap;
|
||||
for (const entry of row) {
|
||||
positioned.push({ ...entry, x: entryX, baseline, row: rowIndex });
|
||||
entryX += entry.width + itemGap;
|
||||
}
|
||||
});
|
||||
|
||||
const legendRects = [
|
||||
{ kind: 'title', x, y: legendTopY, width: 48, height: 14 },
|
||||
...positioned.map((entry) => ({
|
||||
kind: entry.kind,
|
||||
x: entry.x,
|
||||
y: entry.baseline - 10,
|
||||
width: entry.width,
|
||||
height: 14,
|
||||
})),
|
||||
];
|
||||
const collision = legendRects.find((legendRect) => obstacles.some((obstacle) => (
|
||||
Array.isArray(obstacle.start) && Array.isArray(obstacle.end)
|
||||
? segmentIntersectsRect({ start: obstacle.start, end: obstacle.end }, legendRect)
|
||||
: rectsOverlap(obstacle, legendRect)
|
||||
)));
|
||||
if (collision) {
|
||||
if (unfit === 'hide') return null;
|
||||
const message = `[legend/content-overlap] ${diagramType} legend entry "${collision.kind}" overlaps authored relationship geometry.`;
|
||||
throwDiagnosticError(message, [{
|
||||
code: 'legend/content-overlap',
|
||||
severity: 'error',
|
||||
message,
|
||||
subject: { diagramType, path: '/meta/legend' },
|
||||
evidence: { legendKind: collision.kind, legendRect: collision },
|
||||
supportedFixes: ['shorten or hide legend entries, use a wider viewBox, or move the authored relationship route/label out of the legend band'],
|
||||
}]);
|
||||
}
|
||||
|
||||
return {
|
||||
entries: positioned,
|
||||
rowCount: footprint.rowCount,
|
||||
titleY,
|
||||
fontSize,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderLegend({ entries, layout, renderSwatch, locale }) {
|
||||
if (!entries.length) return '';
|
||||
const measured = measureLegend(entries, layout);
|
||||
if (!measured) return '';
|
||||
const hasInteractiveEntries = measured.entries.some((entry) => entry.interactive);
|
||||
const renderedFontSize = measured.fontSize < 8 ? measured.fontSize + 0.5 : measured.fontSize + 2;
|
||||
const rootAttributes = hasInteractiveEntries ? ' data-legend="" data-legend-bridge=""' : ' data-legend=""';
|
||||
const parts = [
|
||||
` <g${rootAttributes}>`,
|
||||
` <text x="${layout.x}" y="${measured.titleY}" class="t-primary" font-size="12" font-weight="650">${esc(translateMessage(locale, 'legend.title'))}</text>`,
|
||||
];
|
||||
|
||||
for (const entry of measured.entries) {
|
||||
const interactive = entry.interactive
|
||||
? ` data-legend-kind="${esc(entry.kind)}" data-legend-label="${esc(entry.label)}"`
|
||||
: '';
|
||||
parts.push(` <g data-legend-semantic-kind="${esc(entry.kind)}"${interactive} data-legend-x="${entry.x}" data-legend-baseline="${entry.baseline}" data-legend-width="${entry.width}">`);
|
||||
parts.push(` ${renderSwatch(entry)}`);
|
||||
parts.push(` <text x="${entry.x + (entry.swatchWidth ?? 14) + (entry.swatchGap ?? DEFAULT_SWATCH_GAP)}" y="${entry.baseline}" class="t-muted" font-size="${renderedFontSize}" font-weight="500">${esc(entry.label)}</text>`);
|
||||
parts.push(' </g>');
|
||||
}
|
||||
parts.push(' </g>');
|
||||
return parts.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const MAX_SYMLINK_DEPTH = 64;
|
||||
const directorySemanticsCache = new Map();
|
||||
let semanticsProbeSequence = 0;
|
||||
|
||||
function splitAbsolute(absolutePath) {
|
||||
const root = path.parse(absolutePath).root;
|
||||
return {
|
||||
root,
|
||||
segments: absolutePath.slice(root.length).split(path.sep).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalize(targetPath, depth) {
|
||||
const absolutePath = path.resolve(targetPath);
|
||||
const { root, segments } = splitAbsolute(absolutePath);
|
||||
let current = root;
|
||||
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
const candidate = path.join(current, segments[index]);
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.lstatSync(candidate);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
||||
return path.resolve(current, ...segments.slice(index));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
if (depth >= MAX_SYMLINK_DEPTH) {
|
||||
const error = new Error(`Could not resolve path because a symbolic-link cycle includes "${candidate}".`);
|
||||
error.code = 'ELOOP';
|
||||
error.path = candidate;
|
||||
throw error;
|
||||
}
|
||||
const link = fs.readlinkSync(candidate);
|
||||
const linkTarget = path.isAbsolute(link) ? link : path.resolve(path.dirname(candidate), link);
|
||||
return canonicalize(path.join(linkTarget, ...segments.slice(index + 1)), depth + 1);
|
||||
}
|
||||
|
||||
current = fs.realpathSync.native(candidate);
|
||||
}
|
||||
|
||||
return path.normalize(current);
|
||||
}
|
||||
|
||||
export function canonicalFuturePath(targetPath) {
|
||||
try {
|
||||
return canonicalize(targetPath, 0);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ELOOP') throw error;
|
||||
const output = path.resolve(targetPath);
|
||||
throw new OutputPathError(`Output path contains a symbolic-link cycle: "${output}".`, {
|
||||
code: 'output/symlink-cycle',
|
||||
message: 'Output path could not be resolved because it contains a symbolic-link cycle.',
|
||||
subject: { output },
|
||||
evidence: {
|
||||
systemCode: 'ELOOP',
|
||||
...(error.path ? { cycleAt: path.resolve(error.path) } : {}),
|
||||
},
|
||||
supportedFixes: ['remove the symbolic-link cycle or choose an output path outside it'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function hasFileIdentity(stat) {
|
||||
return stat.ino !== 0 && stat.ino !== 0n;
|
||||
}
|
||||
|
||||
function sameFileIdentity(left, right) {
|
||||
return hasFileIdentity(left)
|
||||
&& hasFileIdentity(right)
|
||||
&& left.dev === right.dev
|
||||
&& left.ino === right.ino;
|
||||
}
|
||||
|
||||
function nearestExistingDirectory(targetPath) {
|
||||
let directory = path.dirname(targetPath);
|
||||
while (true) {
|
||||
try {
|
||||
const stat = fs.statSync(directory);
|
||||
if (stat.isDirectory()) {
|
||||
return {
|
||||
path: fs.realpathSync.native(directory),
|
||||
stat,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') return null;
|
||||
}
|
||||
const parent = path.dirname(directory);
|
||||
if (parent === directory) return null;
|
||||
directory = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function directoryIdentityKey(directory) {
|
||||
if (!hasFileIdentity(directory.stat)) return null;
|
||||
return `${directory.stat.dev}:${directory.stat.ino}`;
|
||||
}
|
||||
|
||||
function probeNamesAlias(directoryPath, authoredName, lookupName) {
|
||||
let fileDescriptor;
|
||||
let created = false;
|
||||
let result = null;
|
||||
let cleaned = true;
|
||||
const authoredPath = path.join(directoryPath, authoredName);
|
||||
const lookupPath = path.join(directoryPath, lookupName);
|
||||
try {
|
||||
fileDescriptor = fs.openSync(authoredPath, 'wx', 0o600);
|
||||
created = true;
|
||||
fs.closeSync(fileDescriptor);
|
||||
fileDescriptor = undefined;
|
||||
|
||||
let authored;
|
||||
let lookup;
|
||||
try {
|
||||
authored = fs.statSync(authoredPath);
|
||||
lookup = fs.statSync(lookupPath);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') result = false;
|
||||
}
|
||||
if (authored && lookup) {
|
||||
if (sameFileIdentity(authored, lookup)) {
|
||||
result = true;
|
||||
} else {
|
||||
try {
|
||||
result = fs.realpathSync.native(authoredPath) === fs.realpathSync.native(lookupPath);
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
result = null;
|
||||
} finally {
|
||||
if (fileDescriptor !== undefined) {
|
||||
try {
|
||||
fs.closeSync(fileDescriptor);
|
||||
} catch {
|
||||
cleaned = false;
|
||||
}
|
||||
}
|
||||
if (created) {
|
||||
try {
|
||||
fs.unlinkSync(authoredPath);
|
||||
} catch {
|
||||
cleaned = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cleaned ? result : null;
|
||||
}
|
||||
|
||||
function probeDirectorySemantics(directory) {
|
||||
const cacheKey = directoryIdentityKey(directory);
|
||||
if (cacheKey && directorySemanticsCache.has(cacheKey)) {
|
||||
return directorySemanticsCache.get(cacheKey);
|
||||
}
|
||||
|
||||
semanticsProbeSequence += 1;
|
||||
const suffix = `${process.pid}-${Date.now().toString(36)}-${semanticsProbeSequence}`;
|
||||
const caseAuthored = `.archify-Case-Probe-${suffix}`;
|
||||
const normalizationAuthored = `.archify-norm-\u00e9-probe-${suffix}`;
|
||||
const semantics = {
|
||||
caseInsensitive: probeNamesAlias(
|
||||
directory.path,
|
||||
caseAuthored,
|
||||
caseAuthored.toLowerCase(),
|
||||
),
|
||||
normalizationInsensitive: probeNamesAlias(
|
||||
directory.path,
|
||||
normalizationAuthored,
|
||||
normalizationAuthored.normalize('NFD'),
|
||||
),
|
||||
};
|
||||
if (
|
||||
cacheKey
|
||||
&& semantics.caseInsensitive !== null
|
||||
&& semantics.normalizationInsensitive !== null
|
||||
) {
|
||||
directorySemanticsCache.set(cacheKey, semantics);
|
||||
}
|
||||
return semantics;
|
||||
}
|
||||
|
||||
function sameDirectory(left, right) {
|
||||
return left.path === right.path || sameFileIdentity(left.stat, right.stat);
|
||||
}
|
||||
|
||||
function futurePathsAlias(leftPath, rightPath) {
|
||||
const left = canonicalFuturePath(leftPath);
|
||||
const right = canonicalFuturePath(rightPath);
|
||||
if (left === right) return true;
|
||||
|
||||
const leftDirectory = nearestExistingDirectory(left);
|
||||
const rightDirectory = nearestExistingDirectory(right);
|
||||
if (!leftDirectory || !rightDirectory || !sameDirectory(leftDirectory, rightDirectory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const semantics = probeDirectorySemantics(leftDirectory);
|
||||
let comparableLeft = path.relative(leftDirectory.path, left);
|
||||
let comparableRight = path.relative(rightDirectory.path, right);
|
||||
if (semantics.normalizationInsensitive !== false) {
|
||||
comparableLeft = comparableLeft.normalize('NFC');
|
||||
comparableRight = comparableRight.normalize('NFC');
|
||||
}
|
||||
if (semantics.caseInsensitive !== false) {
|
||||
comparableLeft = comparableLeft.toLowerCase();
|
||||
comparableRight = comparableRight.toLowerCase();
|
||||
}
|
||||
return comparableLeft === comparableRight;
|
||||
}
|
||||
|
||||
export function pathsAlias(leftPath, rightPath) {
|
||||
if (futurePathsAlias(leftPath, rightPath)) return true;
|
||||
try {
|
||||
const left = fs.statSync(leftPath);
|
||||
const right = fs.statSync(rightPath);
|
||||
return sameFileIdentity(left, right);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function pathIsInside(directoryPath, targetPath) {
|
||||
const relative = path.relative(canonicalFuturePath(directoryPath), canonicalFuturePath(targetPath));
|
||||
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
|
||||
}
|
||||
|
||||
export class OutputPathError extends Error {
|
||||
constructor(message, diagnostic) {
|
||||
super(message);
|
||||
this.name = 'OutputPathError';
|
||||
this.archifyDiagnostics = [{
|
||||
severity: 'error',
|
||||
subject: {},
|
||||
evidence: {},
|
||||
supportedFixes: [],
|
||||
...diagnostic,
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOutputPath({
|
||||
requestedOutput,
|
||||
authoredOutput,
|
||||
defaultOutput,
|
||||
inputPaths = [],
|
||||
inputDescription = 'an input',
|
||||
otherOutputPaths = [],
|
||||
cwd = process.cwd(),
|
||||
}) {
|
||||
const rawOutput = requestedOutput || authoredOutput || defaultOutput;
|
||||
const source = requestedOutput ? 'cli' : (authoredOutput ? 'meta' : 'default');
|
||||
if (
|
||||
source === 'meta'
|
||||
&& (path.isAbsolute(rawOutput) || path.posix.isAbsolute(rawOutput) || path.win32.isAbsolute(rawOutput))
|
||||
) {
|
||||
throw new OutputPathError('meta.output must be a relative path.', {
|
||||
code: 'output/meta-absolute',
|
||||
message: 'meta.output must be a relative path resolved from the current working directory.',
|
||||
subject: { output: rawOutput },
|
||||
supportedFixes: ['set meta.output to a relative .html path inside the current working directory'],
|
||||
});
|
||||
}
|
||||
if (source === 'meta' && path.extname(rawOutput).toLowerCase() !== '.html') {
|
||||
throw new OutputPathError('meta.output must target an .html file.', {
|
||||
code: 'output/meta-extension',
|
||||
message: 'meta.output must target an .html file.',
|
||||
subject: { output: rawOutput },
|
||||
supportedFixes: ['change meta.output to a path ending in .html'],
|
||||
});
|
||||
}
|
||||
const outputPath = path.resolve(cwd, rawOutput);
|
||||
if (source === 'meta' && path.extname(canonicalFuturePath(outputPath)).toLowerCase() !== '.html') {
|
||||
throw new OutputPathError('meta.output must resolve to an .html file.', {
|
||||
code: 'output/meta-resolved-extension',
|
||||
message: 'meta.output must resolve to an .html file after symbolic links are followed.',
|
||||
subject: { output: rawOutput },
|
||||
supportedFixes: ['remove the symbolic-link alias or point it to an .html target inside the current working directory'],
|
||||
});
|
||||
}
|
||||
if (source === 'meta' && !pathIsInside(cwd, outputPath)) {
|
||||
throw new OutputPathError('meta.output must stay inside the current working directory.', {
|
||||
code: 'output/meta-outside-cwd',
|
||||
message: 'meta.output must stay inside the current working directory after symbolic links are resolved.',
|
||||
subject: { output: rawOutput, cwd: path.resolve(cwd) },
|
||||
supportedFixes: ['set meta.output to a relative .html path inside the current working directory'],
|
||||
});
|
||||
}
|
||||
|
||||
for (const inputPath of inputPaths) {
|
||||
if (!pathsAlias(outputPath, inputPath)) continue;
|
||||
throw new OutputPathError(`Output must not replace ${inputDescription}.`, {
|
||||
code: 'output/input-alias',
|
||||
message: `Output must not replace ${inputDescription}, including through a symbolic-link or future-path alias.`,
|
||||
subject: { output: outputPath, input: path.resolve(inputPath) },
|
||||
supportedFixes: ['choose an output path that is distinct from every input path'],
|
||||
});
|
||||
}
|
||||
for (const otherOutputPath of otherOutputPaths) {
|
||||
if (!pathsAlias(outputPath, otherOutputPath)) continue;
|
||||
throw new OutputPathError('Output targets must use distinct paths.', {
|
||||
code: 'output/target-alias',
|
||||
message: 'Output targets must use distinct paths, including symbolic-link and future-path aliases.',
|
||||
subject: { output: outputPath, conflictingOutput: path.resolve(otherOutputPath) },
|
||||
supportedFixes: ['choose distinct paths for every generated output'],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
outputPath,
|
||||
source,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { throwDiagnosticError } from './diagnostics.mjs';
|
||||
|
||||
const FULL_SHA_RE = /^[a-f0-9]{40}$/i;
|
||||
const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
function evidenceFailure(code, message, { subject = {}, evidence = {}, supportedFixes = [] } = {}) {
|
||||
throwDiagnosticError(message, [{
|
||||
code,
|
||||
severity: 'error',
|
||||
message,
|
||||
subject: { surface: 'repository-evidence', ...subject },
|
||||
evidence,
|
||||
supportedFixes,
|
||||
}]);
|
||||
}
|
||||
|
||||
function runGit(repoRoot, args) {
|
||||
const result = spawnSync('git', ['-C', repoRoot, ...args], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
if (result.error) evidenceFailure('repository-evidence/git-unavailable', `Could not run Git: ${result.error.message}`, {
|
||||
evidence: { reason: result.error.message },
|
||||
supportedFixes: ['install Git and ensure it is available on PATH'],
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function gitValue(repoRoot, args, failure) {
|
||||
const result = runGit(repoRoot, args);
|
||||
if (result.status !== 0) evidenceFailure('repository-evidence/git-command', failure, {
|
||||
evidence: { gitArgs: args, exitCode: result.status },
|
||||
supportedFixes: ['use the intended local Git repository and verify its origin and revision'],
|
||||
});
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function githubSlug(value) {
|
||||
const raw = String(value || '').trim();
|
||||
const match = raw.match(/^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i);
|
||||
return match ? `${match[1]}/${match[2]}`.toLowerCase() : null;
|
||||
}
|
||||
|
||||
function verifiedSourcePath(value, where) {
|
||||
const sourcePath = String(value || '');
|
||||
if (!sourcePath || sourcePath.startsWith('/') || sourcePath.includes('\\') || CONTROL_CHARACTER_RE.test(sourcePath)) {
|
||||
evidenceFailure('repository-evidence/path-invalid', `${where} must be a repo-relative POSIX path.`, {
|
||||
subject: { path: where },
|
||||
evidence: { authoredPath: sourcePath },
|
||||
supportedFixes: ['use a repository-relative path with forward slashes'],
|
||||
});
|
||||
}
|
||||
const segments = sourcePath.split('/');
|
||||
if (segments.some((segment) => !segment || segment === '.' || segment === '..') || segments[0] === '.git') {
|
||||
evidenceFailure('repository-evidence/path-escape', `${where} must stay inside the repository and may not address .git.`, {
|
||||
subject: { path: where },
|
||||
evidence: { authoredPath: sourcePath },
|
||||
supportedFixes: ['remove empty, dot, parent, or .git path segments'],
|
||||
});
|
||||
}
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
function sourceHref(repositoryUrl, revision, source) {
|
||||
const encodedPath = source.path.split('/').map(encodeURIComponent).join('/');
|
||||
const lineFragment = source.line
|
||||
? `#L${source.line}${source.endLine && source.endLine !== source.line ? `-L${source.endLine}` : ''}`
|
||||
: '';
|
||||
return `${repositoryUrl}/blob/${revision}/${encodedPath}${lineFragment}`;
|
||||
}
|
||||
|
||||
function sourceLineCount(content) {
|
||||
if (!content.length) return 0;
|
||||
const lines = content.split(/\r\n|\n|\r/);
|
||||
return lines.length - (/(?:\r\n|\n|\r)$/.test(content) ? 1 : 0);
|
||||
}
|
||||
|
||||
export function hasRepositoryEvidence(diagramType, diagram) {
|
||||
if (diagramType !== 'architecture') return false;
|
||||
const components = Array.isArray(diagram?.components) ? diagram.components : [];
|
||||
return Boolean(diagram?.meta?.repository) || components.some((component) => Array.isArray(component?.sources) && component.sources.length);
|
||||
}
|
||||
|
||||
export function verifyRepositoryEvidence(diagramType, diagram, repoRootInput) {
|
||||
if (!hasRepositoryEvidence(diagramType, diagram)) return null;
|
||||
if (diagramType !== 'architecture') evidenceFailure('repository-evidence/type-unsupported', 'Repository evidence is currently supported for architecture diagrams only.', {
|
||||
subject: { diagramType },
|
||||
supportedFixes: ['use architecture mode or remove repository evidence'],
|
||||
});
|
||||
|
||||
const repository = diagram.meta?.repository;
|
||||
if (!repository) evidenceFailure('repository-evidence/repository-required', 'Repository evidence requires /meta/repository.', {
|
||||
subject: { path: '/meta/repository' },
|
||||
supportedFixes: ['add the pinned public repository metadata or remove component sources'],
|
||||
});
|
||||
if (!FULL_SHA_RE.test(repository.revision || '')) {
|
||||
evidenceFailure('repository-evidence/revision-invalid', '/meta/repository/revision must be a full 40-character commit SHA.', {
|
||||
subject: { path: '/meta/repository/revision' },
|
||||
evidence: { revision: repository.revision },
|
||||
supportedFixes: ['pin one full 40-character commit SHA'],
|
||||
});
|
||||
}
|
||||
const authoredSlug = githubSlug(repository.url);
|
||||
if (!authoredSlug || !String(repository.url).startsWith('https://github.com/')) {
|
||||
evidenceFailure('repository-evidence/url-invalid', '/meta/repository/url must be a public https://github.com owner/repository URL.', {
|
||||
subject: { path: '/meta/repository/url' },
|
||||
evidence: { repositoryUrl: repository.url },
|
||||
supportedFixes: ['use the canonical public GitHub HTTPS repository URL'],
|
||||
});
|
||||
}
|
||||
if (!repoRootInput) {
|
||||
evidenceFailure('repository-evidence/root-required', 'This diagram declares source evidence. Pass --repo-root <repository> so Archify can verify it before rendering.', {
|
||||
subject: { path: '/meta/repository' },
|
||||
supportedFixes: ['pass --repo-root with the matching local Git checkout'],
|
||||
});
|
||||
}
|
||||
|
||||
const requestedRoot = path.resolve(repoRootInput);
|
||||
let realRoot;
|
||||
try {
|
||||
realRoot = fs.realpathSync(requestedRoot);
|
||||
} catch (error) {
|
||||
evidenceFailure('repository-evidence/root-unreadable', `Could not resolve evidence repository root "${requestedRoot}": ${error.message}`, {
|
||||
subject: { repoRoot: requestedRoot },
|
||||
evidence: { reason: error.message },
|
||||
supportedFixes: ['pass one readable local repository directory'],
|
||||
});
|
||||
}
|
||||
const gitRoot = gitValue(realRoot, ['rev-parse', '--show-toplevel'], `Evidence root "${realRoot}" is not a Git repository.`);
|
||||
if (fs.realpathSync(gitRoot) !== realRoot) {
|
||||
evidenceFailure('repository-evidence/root-not-top-level', `Evidence root must be the Git top-level directory: ${gitRoot}`, {
|
||||
subject: { repoRoot: realRoot },
|
||||
evidence: { gitTopLevel: gitRoot },
|
||||
supportedFixes: [`pass --repo-root ${gitRoot}`],
|
||||
});
|
||||
}
|
||||
const origin = gitValue(realRoot, ['remote', 'get-url', 'origin'], 'Evidence repository must have an origin remote.');
|
||||
if (githubSlug(origin) !== authoredSlug) {
|
||||
evidenceFailure('repository-evidence/origin-mismatch', `Evidence repository origin ${JSON.stringify(origin)} does not match ${JSON.stringify(repository.url)}.`, {
|
||||
subject: { repoRoot: realRoot },
|
||||
evidence: { localOrigin: origin, authoredRepository: repository.url },
|
||||
supportedFixes: ['use the matching local checkout or correct the authored repository URL'],
|
||||
});
|
||||
}
|
||||
|
||||
const revision = repository.revision.toLowerCase();
|
||||
const commit = runGit(realRoot, ['cat-file', '-e', `${revision}^{commit}`]);
|
||||
if (commit.status !== 0) {
|
||||
evidenceFailure('repository-evidence/revision-unavailable', `Evidence revision ${revision} is not available in the local repository.`, {
|
||||
subject: { repoRoot: realRoot },
|
||||
evidence: { revision },
|
||||
supportedFixes: ['fetch the pinned commit or pin an available full commit SHA'],
|
||||
});
|
||||
}
|
||||
|
||||
const nodes = Object.create(null);
|
||||
let referenceCount = 0;
|
||||
const components = Array.isArray(diagram.components) ? diagram.components : [];
|
||||
for (const [componentIndex, component] of components.entries()) {
|
||||
if (!Array.isArray(component.sources) || component.sources.length === 0) continue;
|
||||
const verified = [];
|
||||
for (const [sourceIndex, authored] of component.sources.entries()) {
|
||||
const where = `/components/${componentIndex}/sources/${sourceIndex}/path`;
|
||||
const source = {
|
||||
path: verifiedSourcePath(authored.path, where),
|
||||
...(authored.line ? { line: authored.line } : {}),
|
||||
...(authored.end_line ? { endLine: authored.end_line } : {}),
|
||||
...(authored.label ? { label: authored.label } : {}),
|
||||
};
|
||||
if (source.endLine && !source.line) {
|
||||
evidenceFailure('repository-evidence/line-required', `/components/${componentIndex}/sources/${sourceIndex}/end_line requires line.`, {
|
||||
subject: { path: `/components/${componentIndex}/sources/${sourceIndex}/end_line`, componentId: component.id },
|
||||
supportedFixes: ['add line or remove end_line'],
|
||||
});
|
||||
}
|
||||
if (source.endLine && source.endLine < source.line) {
|
||||
evidenceFailure('repository-evidence/line-range-invalid', `/components/${componentIndex}/sources/${sourceIndex}/end_line must be greater than or equal to line.`, {
|
||||
subject: { path: `/components/${componentIndex}/sources/${sourceIndex}`, componentId: component.id },
|
||||
evidence: { line: source.line, endLine: source.endLine },
|
||||
supportedFixes: ['use an end_line greater than or equal to line'],
|
||||
});
|
||||
}
|
||||
const object = `${revision}:${source.path}`;
|
||||
const type = runGit(realRoot, ['cat-file', '-t', object]);
|
||||
if (type.status !== 0 || type.stdout.trim() !== 'blob') {
|
||||
evidenceFailure('repository-evidence/file-missing', `${where} does not identify a file at revision ${revision}.`, {
|
||||
subject: { path: where, componentId: component.id },
|
||||
evidence: { sourcePath: source.path, revision },
|
||||
supportedFixes: ['use a file path that exists at the pinned revision'],
|
||||
});
|
||||
}
|
||||
if (source.line) {
|
||||
const content = runGit(realRoot, ['show', object]);
|
||||
if (content.status !== 0) evidenceFailure('repository-evidence/file-unreadable', `${where} could not be read at revision ${revision}.`, {
|
||||
subject: { path: where, componentId: component.id },
|
||||
evidence: { sourcePath: source.path, revision },
|
||||
supportedFixes: ['verify the pinned blob is readable in the local checkout'],
|
||||
});
|
||||
const lineCount = sourceLineCount(content.stdout);
|
||||
const requestedLine = source.endLine || source.line;
|
||||
if (requestedLine > lineCount) {
|
||||
evidenceFailure('repository-evidence/line-out-of-range', `/components/${componentIndex}/sources/${sourceIndex} requests line ${requestedLine}, but ${source.path} has ${lineCount} lines at revision ${revision}.`, {
|
||||
subject: { path: `/components/${componentIndex}/sources/${sourceIndex}`, componentId: component.id },
|
||||
evidence: { sourcePath: source.path, requestedLine, lineCount, revision },
|
||||
supportedFixes: ['use a line range that exists at the pinned revision'],
|
||||
});
|
||||
}
|
||||
}
|
||||
verified.push({ ...source, href: sourceHref(repository.url.replace(/\.git\/?$/i, '').replace(/\/$/, ''), revision, source) });
|
||||
referenceCount += 1;
|
||||
}
|
||||
nodes[component.id] = verified;
|
||||
}
|
||||
if (referenceCount === 0) {
|
||||
evidenceFailure('repository-evidence/source-required', '/meta/repository requires at least one component source reference.', {
|
||||
subject: { path: '/meta/repository' },
|
||||
supportedFixes: ['add at least one verified component source or remove repository metadata'],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
verified: true,
|
||||
repository: {
|
||||
url: repository.url.replace(/\.git\/?$/i, '').replace(/\/$/, ''),
|
||||
revision,
|
||||
shortRevision: revision.slice(0, 7),
|
||||
},
|
||||
referenceCount,
|
||||
nodes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Single-line node text fitting, shared by every renderer.
|
||||
//
|
||||
// Node text (`label`, `sublabel`, `tag`) renders as one <text> element with
|
||||
// text-anchor="middle" and is never wrapped. Left unmeasured, an over-long
|
||||
// value silently spills across its neighbours while validation still reports
|
||||
// a clean receipt — the failure mode this module exists to close.
|
||||
//
|
||||
// Two halves, always used together:
|
||||
// - fittedNodeFontSize shrinks the text toward a legible minimum at render
|
||||
// time, so ordinary overruns simply get smaller instead of overlapping.
|
||||
// - minimumNodeTextWidth reports the width the text still needs once it has
|
||||
// shrunk as far as it may, so validation can reject what shrinking cannot
|
||||
// save.
|
||||
//
|
||||
// The geometry constants below are shared; the per-field `preferred` and
|
||||
// `minimum` font sizes are not, because renderers set node text at different
|
||||
// sizes (architecture sublabels are 9px, the rest are 7px).
|
||||
|
||||
import { textUnits } from './utils.mjs';
|
||||
|
||||
// widthFactor: px of advance width per text unit, per px of font size.
|
||||
// horizontalPadding: total px reserved inside the box so text never touches
|
||||
// the border.
|
||||
export const nodeTextFit = {
|
||||
widthFactor: 0.6,
|
||||
horizontalPadding: 8,
|
||||
};
|
||||
|
||||
// Largest font size at or below `preferred` that fits `text` inside `width`,
|
||||
// floored at `minimum` — below that the text is no longer legible and the
|
||||
// caller should be reporting a problem instead.
|
||||
export function fittedNodeFontSize(text, width, preferred, minimum) {
|
||||
const units = Math.max(1, textUnits(text));
|
||||
const available = Math.max(1, width - nodeTextFit.horizontalPadding);
|
||||
const fitted = Math.min(preferred, available / (units * nodeTextFit.widthFactor));
|
||||
return Math.max(minimum, Math.floor(fitted * 10) / 10);
|
||||
}
|
||||
|
||||
// Width `text` occupies at its legible minimum. Compare against
|
||||
// `width - nodeTextFit.horizontalPadding` to decide whether shrink-to-fit can
|
||||
// rescue it.
|
||||
export function minimumNodeTextWidth(text, minimum) {
|
||||
return textUnits(text) * minimum * nodeTextFit.widthFactor;
|
||||
}
|
||||
|
||||
// Available text width inside a box of `width`.
|
||||
export function availableNodeTextWidth(width) {
|
||||
return width - nodeTextFit.horizontalPadding;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import {
|
||||
escapeHtml as esc,
|
||||
localizeTemplate,
|
||||
resolveLocale,
|
||||
translateMessage,
|
||||
viewerCatalog,
|
||||
} from './i18n.mjs';
|
||||
|
||||
export { esc };
|
||||
|
||||
export function renderDefinitions() {
|
||||
return ` <!-- Definitions -->
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" class="m-default" />
|
||||
</marker>
|
||||
<marker id="arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
|
||||
</marker>
|
||||
<marker id="arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" class="m-security" />
|
||||
</marker>
|
||||
<marker id="arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
|
||||
</marker>
|
||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
</defs>`;
|
||||
}
|
||||
|
||||
const SIGIL_TONE = {
|
||||
frontend: 'frontend',
|
||||
start: 'frontend',
|
||||
backend: 'backend',
|
||||
active: 'backend',
|
||||
database: 'database',
|
||||
success: 'database',
|
||||
cloud: 'cloud',
|
||||
waiting: 'cloud',
|
||||
security: 'security',
|
||||
failure: 'security',
|
||||
messagebus: 'messagebus',
|
||||
external: 'external',
|
||||
neutral: 'external',
|
||||
};
|
||||
|
||||
const SIGIL_SHAPE = {
|
||||
frontend: `<rect x="2" y="3" width="12" height="10" rx="2"/>
|
||||
<path d="M2 6.5h12"/>
|
||||
<circle cx="4.1" cy="4.8" r=".7" class="sigil-fill"/>
|
||||
<circle cx="6.3" cy="4.8" r=".7" class="sigil-fill"/>`,
|
||||
backend: `<path d="M6 3 3 8l3 5M10 3l3 5-3 5"/>`,
|
||||
database: `<ellipse cx="8" cy="4" rx="5" ry="2"/>
|
||||
<path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2"/>`,
|
||||
cloud: `<path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z"/>`,
|
||||
security: `<path d="M8 2.2 13 4v3.5c0 3.1-1.8 5.4-5 6.5-3.2-1.1-5-3.4-5-6.5V4Z"/>
|
||||
<path d="m5.8 8 1.5 1.5 3-3"/>`,
|
||||
messagebus: `<path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/>
|
||||
<circle cx="5" cy="4.5" r="1" class="sigil-fill"/>
|
||||
<circle cx="10.5" cy="8" r="1" class="sigil-fill"/>
|
||||
<circle cx="7" cy="11.5" r="1" class="sigil-fill"/>`,
|
||||
external: `<rect x="2.5" y="5" width="8.5" height="8" rx="1.5"/>
|
||||
<path d="M8 2.5h5.5V8M13.5 2.5 7.5 8.5"/>`,
|
||||
start: `<circle cx="8" cy="8" r="5"/>
|
||||
<path d="m7 5.4 3.6 2.6L7 10.6Z" class="sigil-fill"/>`,
|
||||
active: `<path d="M2 8h3l1.5-3.5L9 12l1.6-4H14"/>`,
|
||||
waiting: `<path d="M4 2.5h8M4 13.5h8M5 3c0 2.8 2 3.2 3 5-1 1.8-3 2.2-3 5M11 3c0 2.8-2 3.2-3 5 1 1.8 3 2.2 3 5"/>`,
|
||||
success: `<circle cx="8" cy="8" r="5.3"/>
|
||||
<path d="m5.2 8 1.8 1.8 3.8-4"/>`,
|
||||
failure: `<circle cx="8" cy="8" r="5.3"/>
|
||||
<path d="m5.7 5.7 4.6 4.6m0-4.6-4.6 4.6"/>`,
|
||||
neutral: `<rect x="3" y="3" width="10" height="10" rx="2"/>
|
||||
<circle cx="8" cy="8" r="1.2" class="sigil-fill"/>`,
|
||||
};
|
||||
|
||||
// A quiet, renderer-owned role stamp. It is authored SVG content rather than a
|
||||
// viewer overlay, so it survives canonical export while adding no focus target,
|
||||
// accessible name, layout box, or interaction state of its own.
|
||||
export function renderSemanticSigil(kind, { x, y, size = 11 } = {}) {
|
||||
const normalized = Object.hasOwn(SIGIL_SHAPE, kind) ? kind : 'neutral';
|
||||
const tone = SIGIL_TONE[normalized] || 'external';
|
||||
const scale = size / 16;
|
||||
return `<g aria-hidden="true" data-semantic-sigil="${esc(normalized)}" class="semantic-sigil s-${tone}" transform="translate(${x} ${y}) scale(${scale})">
|
||||
${SIGIL_SHAPE[normalized]}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
export function renderCards(cards) {
|
||||
const list = Array.isArray(cards) ? cards : [];
|
||||
return ` <!-- Info Cards -->
|
||||
<div class="cards">
|
||||
${list.map((card) => ` <div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-dot ${esc(card.dot)}"></div>
|
||||
<h3>${esc(card.title)}</h3>
|
||||
</div>
|
||||
<ul>
|
||||
${card.items.map((item) => ` <li>• ${esc(item)}</li>`).join('\n')}
|
||||
</ul>
|
||||
</div>`).join('\n\n')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const SVG_SLOT_RE = / <!-- ARCHIFY:SVG_SLOT_START -->[\s\S]*? <!-- ARCHIFY:SVG_SLOT_END -->/;
|
||||
const CARDS_SLOT_RE = / <!-- ARCHIFY:CARDS_SLOT_START -->[\s\S]*? <!-- ARCHIFY:CARDS_SLOT_END -->/;
|
||||
const SUBTITLE_SLOT_RE = /^([ \t]*)<p class="subtitle">\[Subtitle description\]<\/p>[ \t]*(\r?\n)?/m;
|
||||
const GUIDED_VIEWS_PLACEHOLDER = '<!-- ARCHIFY:GUIDED_VIEWS_DATA -->';
|
||||
const SOURCE_EVIDENCE_PLACEHOLDER = ' <!-- ARCHIFY:SOURCE_EVIDENCE_DATA -->';
|
||||
const I18N_PLACEHOLDER = ' <!-- ARCHIFY:I18N_DATA -->';
|
||||
|
||||
function serializeScriptJson(value) {
|
||||
return JSON.stringify(value)
|
||||
.replaceAll('<', '\\u003c')
|
||||
.replaceAll('>', '\\u003e')
|
||||
.replaceAll('&', '\\u0026');
|
||||
}
|
||||
|
||||
const TEMPLATE_PLACEHOLDERS = [
|
||||
'<html lang="en" data-theme="dark" data-preset="[VISUAL PRESET]">',
|
||||
'<title>[PROJECT NAME] Architecture Diagram</title>',
|
||||
'<h1>[PROJECT NAME] Architecture</h1>',
|
||||
GUIDED_VIEWS_PLACEHOLDER,
|
||||
];
|
||||
|
||||
export function applyTemplate(template, {
|
||||
title,
|
||||
subtitle,
|
||||
svg,
|
||||
cards,
|
||||
locale,
|
||||
visualPreset = 'classic',
|
||||
guidedViews = [],
|
||||
sourceEvidence = null,
|
||||
}) {
|
||||
if (!SVG_SLOT_RE.test(template)) {
|
||||
throw new Error('applyTemplate: template missing ARCHIFY:SVG_SLOT sentinel');
|
||||
}
|
||||
if (!CARDS_SLOT_RE.test(template)) {
|
||||
throw new Error('applyTemplate: template missing ARCHIFY:CARDS_SLOT sentinel');
|
||||
}
|
||||
if (!SUBTITLE_SLOT_RE.test(template)) {
|
||||
throw new Error('applyTemplate: template missing subtitle placeholder');
|
||||
}
|
||||
for (const ph of TEMPLATE_PLACEHOLDERS) {
|
||||
if (!template.includes(ph)) {
|
||||
throw new Error(`applyTemplate: template missing placeholder ${JSON.stringify(ph)}`);
|
||||
}
|
||||
}
|
||||
// Keep existing custom templates compatible when evidence is not requested.
|
||||
// Silently dropping verified evidence would be misleading, so the new slot
|
||||
// becomes mandatory only for the opt-in evidence path.
|
||||
if (sourceEvidence && !template.includes(SOURCE_EVIDENCE_PLACEHOLDER)) {
|
||||
throw new Error(`applyTemplate: repository evidence requires placeholder ${JSON.stringify(SOURCE_EVIDENCE_PLACEHOLDER)}`);
|
||||
}
|
||||
// Function replacers: a literal `$&`, `$'`, `$\`` or `$$` in titles, labels,
|
||||
// or rendered SVG must not be interpreted as a replacement pattern.
|
||||
const guidedViewsJson = serializeScriptJson(guidedViews);
|
||||
const sourceEvidenceJson = serializeScriptJson(sourceEvidence);
|
||||
const resolvedLocale = resolveLocale(locale);
|
||||
const i18nJson = serializeScriptJson({ locale: resolvedLocale, messages: viewerCatalog(resolvedLocale) });
|
||||
const renderedSubtitle = typeof subtitle === 'string' && subtitle.trim()
|
||||
? `<p class="subtitle">${esc(subtitle)}</p>`
|
||||
: '';
|
||||
const i18nData = ` <script id="archify-i18n-data" type="application/json">${i18nJson}</script>`;
|
||||
const localizedTemplate = localizeTemplate(template, resolvedLocale);
|
||||
const templateWithI18n = localizedTemplate.includes(I18N_PLACEHOLDER)
|
||||
? localizedTemplate.replace(I18N_PLACEHOLDER, () => i18nData)
|
||||
: localizedTemplate.replace(GUIDED_VIEWS_PLACEHOLDER, () => `${i18nData}\n ${GUIDED_VIEWS_PLACEHOLDER}`);
|
||||
return templateWithI18n
|
||||
.replace(TEMPLATE_PLACEHOLDERS[0], () => `<html lang="${esc(resolvedLocale)}" data-theme="dark" data-preset="${esc(visualPreset)}">`)
|
||||
.replace(TEMPLATE_PLACEHOLDERS[1], () => `<title>${esc(translateMessage(resolvedLocale, 'page.title', { title }))}</title>`)
|
||||
.replace(TEMPLATE_PLACEHOLDERS[2], () => `<h1>${esc(title)}</h1>`)
|
||||
.replace(SUBTITLE_SLOT_RE, (_match, indent, newline = '') => renderedSubtitle
|
||||
? `${indent}${renderedSubtitle}${newline}`
|
||||
: '')
|
||||
.replace(SVG_SLOT_RE, () => svg)
|
||||
.replace(CARDS_SLOT_RE, () => cards)
|
||||
.replace(GUIDED_VIEWS_PLACEHOLDER, () => `<script id="archify-guided-views-data" type="application/json">${guidedViewsJson}</script>`)
|
||||
.replace(SOURCE_EVIDENCE_PLACEHOLDER, () => sourceEvidence
|
||||
? ` <script id="archify-source-evidence-data" type="application/json">${sourceEvidenceJson}</script>`
|
||||
: '');
|
||||
}
|
||||
|
||||
// CJK and other wide/fullwidth glyphs render at roughly twice the advance
|
||||
// width of ASCII in the monospace stacks the template uses. Keep halfwidth
|
||||
// forms (notably U+FF61–U+FF9F Katakana) out of this set. The explicit ranges
|
||||
// also cover vertical punctuation and supplementary East Asian scripts that
|
||||
// literal glyph ranges made difficult to audit.
|
||||
// Code points that take two columns of advance width: East Asian Wide and
|
||||
// Fullwidth per UAX #11, tracking Unicode 17.0. That takes in the BMP symbols
|
||||
// carrying emoji presentation (U+2705, U+2B50, U+26A1, U+231B, ...), which
|
||||
// render at the same square advance as the supplementary-plane emoji already
|
||||
// listed here, and Hangul Jamo Extended-A. Two boundary calls worth naming:
|
||||
// Unicode 16.0 reclassified the trigrams (U+2630-U+2637) and the monogram /
|
||||
// digram symbols (U+268A-U+268F) from Neutral to Wide, so both are in; and
|
||||
// Hangul Jamo Extended-A stops at U+A97C, its last assigned jamo, because
|
||||
// U+A97D-U+A97F are unassigned, and unassigned code points outside the CJK
|
||||
// ranges UAX #11 names default to Neutral rather than Wide. Spelled out as
|
||||
// ranges because V8 has no \p{East_Asian_Width=W} property escape.
|
||||
const FULLWIDTH_RE = /[\u1100-\u115F\u231A-\u231B\u2329-\u232A\u23E9-\u23EC\u23F0\u23F3\u25FD-\u25FE\u2614-\u2615\u2630-\u2637\u2648-\u2653\u267F\u268A-\u268F\u2693\u26A1\u26AA-\u26AB\u26BD-\u26BE\u26C4-\u26C5\u26CE\u26D4\u26EA\u26F2-\u26F3\u26F5\u26FA\u26FD\u2705\u270A-\u270B\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B-\u2B1C\u2B50\u2B55\u2E80-\uA4CF\uA960-\uA97C\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF01-\uFF60\uFFE0-\uFFE6\u{16FE0}-\u{18DFF}\u{1AFF0}-\u{1AFFF}\u{1B000}-\u{1B2FF}\u{1F000}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u;
|
||||
|
||||
// A variation selector (U+FE00-U+FE0F) carries no advance of its own: it
|
||||
// re-presents the character before it. VS15 (U+FE0E) asks for text
|
||||
// presentation, which renders narrow; VS16 (U+FE0F) asks for emoji
|
||||
// presentation, which renders at the square emoji advance. So a base plus a
|
||||
// selector is measured from the selector, not from the base -- otherwise
|
||||
// widening the emoji-presentation bases above turns U+2B50 U+FE0F from two
|
||||
// units into three while the glyph on screen stays one square, and leaves
|
||||
// U+2708 U+FE0F at two only because its base happens to be narrow.
|
||||
//
|
||||
// A selector following a base that cannot take emoji presentation is
|
||||
// malformed input; measuring it wide is the safe direction here, since
|
||||
// over-measuring pads a box while under-measuring spills the label out of it.
|
||||
const VARIATION_SELECTOR_FIRST = 0xfe00;
|
||||
const VARIATION_SELECTOR_LAST = 0xfe0f;
|
||||
const VARIATION_SELECTOR_TEXT = 0xfe0e;
|
||||
const VARIATION_SELECTOR_EMOJI = 0xfe0f;
|
||||
|
||||
export function textUnits(text) {
|
||||
const chars = Array.from(String(text ?? ''));
|
||||
let units = 0;
|
||||
for (let i = 0; i < chars.length; i += 1) {
|
||||
const codePoint = chars[i].codePointAt(0);
|
||||
if (codePoint >= VARIATION_SELECTOR_FIRST && codePoint <= VARIATION_SELECTOR_LAST) continue;
|
||||
const next = i + 1 < chars.length ? chars[i + 1].codePointAt(0) : -1;
|
||||
if (next === VARIATION_SELECTOR_EMOJI) units += 2;
|
||||
else if (next === VARIATION_SELECTOR_TEXT) units += 1;
|
||||
else units += FULLWIDTH_RE.test(chars[i]) ? 2 : 1;
|
||||
}
|
||||
return units;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import * as validators from './generated-validators.mjs';
|
||||
import { throwDiagnosticError } from './diagnostics.mjs';
|
||||
|
||||
// "/nodes/3/label" reads much better as "/nodes/3 (id: "router") /label" for the
|
||||
// LLM fixing the JSON; resolve the nearest enclosing element's id or label.
|
||||
function annotatedPath(instancePath, data) {
|
||||
if (!instancePath) return { path: '/', identity: null };
|
||||
let node = data;
|
||||
let hint = null;
|
||||
for (const seg of instancePath.split('/').slice(1)) {
|
||||
if (node == null || typeof node !== 'object') break;
|
||||
node = node[/^\d+$/.test(seg) ? Number(seg) : seg];
|
||||
if (node && typeof node === 'object' && !Array.isArray(node)) {
|
||||
const tag = node.id ?? node.label;
|
||||
if (tag != null) hint = String(tag);
|
||||
}
|
||||
}
|
||||
return { path: instancePath, identity: hint };
|
||||
}
|
||||
|
||||
function annotatePath(instancePath, data) {
|
||||
const annotated = annotatedPath(instancePath, data);
|
||||
return annotated.identity != null
|
||||
? `${annotated.path} (id/label: ${JSON.stringify(annotated.identity)})`
|
||||
: annotated.path;
|
||||
}
|
||||
|
||||
function formatErrors(errors, data) {
|
||||
return errors.map((e) => {
|
||||
const where = annotatePath(e.instancePath, data);
|
||||
const detail = e.params && Object.keys(e.params).length
|
||||
? ' ' + JSON.stringify(e.params)
|
||||
: '';
|
||||
return ` ${where} ${e.message}${detail}`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
export function validateSchema(diagramType, data) {
|
||||
const validate = validators[diagramType];
|
||||
if (!validate) {
|
||||
throw new Error(`validateSchema: unknown diagram type "${diagramType}"`);
|
||||
}
|
||||
if (!validate(data)) {
|
||||
const diagnostics = validate.errors.map((error) => {
|
||||
const annotated = annotatedPath(error.instancePath, data);
|
||||
const subject = {
|
||||
diagramType,
|
||||
path: annotated.path,
|
||||
...(annotated.identity != null ? { identity: String(annotated.identity) } : {}),
|
||||
};
|
||||
const evidence = {
|
||||
keyword: error.keyword,
|
||||
expected: error.schema,
|
||||
...error.params,
|
||||
};
|
||||
const supportedFixes = {
|
||||
additionalProperties: [`remove unsupported property ${JSON.stringify(error.params?.additionalProperty)}`],
|
||||
required: [`add required property ${JSON.stringify(error.params?.missingProperty)}`],
|
||||
type: [`use ${JSON.stringify(error.params?.type)} at ${annotated.path}`],
|
||||
enum: [`choose one of ${JSON.stringify(error.params?.allowedValues || [])}`],
|
||||
pattern: [`match the required pattern ${JSON.stringify(error.params?.pattern)}`],
|
||||
minimum: [`use a value ${error.params?.comparison || '>='} ${error.params?.limit}`],
|
||||
maximum: [`use a value ${error.params?.comparison || '<='} ${error.params?.limit}`],
|
||||
minItems: [`provide at least ${error.params?.limit} item(s)`],
|
||||
maxItems: [`provide at most ${error.params?.limit} item(s)`],
|
||||
minLength: [`provide at least ${error.params?.limit} character(s)`],
|
||||
maxLength: [`provide at most ${error.params?.limit} character(s)`],
|
||||
}[error.keyword] || [];
|
||||
const detail = error.params && Object.keys(error.params).length
|
||||
? ` ${JSON.stringify(error.params)}`
|
||||
: '';
|
||||
return {
|
||||
code: `schema/${error.keyword}`,
|
||||
severity: 'error',
|
||||
message: `${annotatePath(error.instancePath, data)} ${error.message}${detail}`,
|
||||
subject,
|
||||
evidence,
|
||||
supportedFixes,
|
||||
};
|
||||
});
|
||||
throwDiagnosticError(
|
||||
`${diagramType} schema validation failed:\n${formatErrors(validate.errors, data)}`,
|
||||
diagnostics,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
# Workflow Renderer
|
||||
|
||||
Render `diagram_type: "workflow"` JSON files into the standard Archify HTML
|
||||
template.
|
||||
|
||||
```bash
|
||||
node archify/renderers/workflow/render-workflow.mjs input.workflow.json output.html
|
||||
```
|
||||
|
||||
The renderer validates input against `archify/schemas/workflow.schema.json`
|
||||
with the bundled standalone validator. No dependency installation is required.
|
||||
|
||||
If `output.html` is omitted, the renderer uses `meta.output` from the JSON file
|
||||
or falls back to `workflow.html` in the current working directory.
|
||||
|
||||
After rendering, run the artifact checker:
|
||||
|
||||
```bash
|
||||
node archify/scripts/check-render-output.mjs output.html
|
||||
```
|
||||
|
||||
It catches final-SVG issues that are easiest to see in a browser: non-finite
|
||||
SVG values, accidental two-point diagonal arrows, and arrows crossing the
|
||||
legend.
|
||||
|
||||
## Input
|
||||
|
||||
Workflow JSON files must set:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 2,
|
||||
"diagram_type": "workflow",
|
||||
"meta": {
|
||||
"title": "Agent Tool Call Workflow"
|
||||
},
|
||||
"lanes": [],
|
||||
"phases": [],
|
||||
"groups": [],
|
||||
"mainPath": [],
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"cards": []
|
||||
}
|
||||
```
|
||||
|
||||
Use `schema_version: 2` for new workflows. Its readable layout compiler treats
|
||||
every `col` as a logical rank in `0..5` and derives geometry from the measured
|
||||
document. `schema_version: 1` remains the fixed legacy contract for existing
|
||||
sources; valid v1 output is preserved byte-for-byte and never silently
|
||||
reinterpreted as v2.
|
||||
|
||||
Omit `meta.viewBox` for the common v2 case so the compiler can use intrinsic
|
||||
measured bounds. In v1, the omitted width remains fixed at 720 and height is
|
||||
derived from lane count. A complete worked example lives at
|
||||
`archify/examples/agent-tool-call.workflow.json`; its `schema_version` selects
|
||||
the applicable contract.
|
||||
|
||||
The schema lives at:
|
||||
|
||||
```text
|
||||
archify/schemas/workflow.schema.json
|
||||
```
|
||||
|
||||
## Migration and layout receipt
|
||||
|
||||
Migrate an existing v1 source into a separate v2 file:
|
||||
|
||||
```bash
|
||||
node archify/bin/archify.mjs migrate workflow old.json new.json --to-schema 2 --json
|
||||
```
|
||||
|
||||
Running the command again with its schema-v2 output as the new source is an
|
||||
idempotent verification pass: the destination bytes and geometry stay unchanged.
|
||||
|
||||
The command never overwrites the source by default. It maps absolute
|
||||
`via[*][0]`, `labelAt[0]`, and `channelX` values from legacy to solved rank
|
||||
space, preserves y coordinates unless a reported vertical constraint needs
|
||||
author input, expands an explicit viewBox only for an unambiguous containment
|
||||
repair, and writes the destination only after v2 compilation and artifact
|
||||
checks pass. Ambiguous explicit pins fail without producing the destination.
|
||||
|
||||
Inspect the stable author-facing v2 plan with:
|
||||
|
||||
```bash
|
||||
node archify/bin/archify.mjs validate workflow input.workflow.json --layout-json
|
||||
```
|
||||
|
||||
The receipt reports the selected contract, measured `viewBox` and
|
||||
`requiredViewBox`, solved columns, nodes, edges, labels, and causal diagnostics.
|
||||
It deliberately omits solver iterations and candidate scores.
|
||||
|
||||
## Legend
|
||||
|
||||
The default legend derives component kinds from `nodes[].type`. Supported
|
||||
`meta.legend.entries` keys, in stable order, are `frontend`, `backend`,
|
||||
`security`, `messagebus`, `database`, `cloud`, and `external`. Labels and
|
||||
visibility may be overridden through the shared legend contract; only kinds
|
||||
backed by rendered nodes receive Semantic Legend controls.
|
||||
|
||||
## Layout contracts
|
||||
|
||||
### Fixed v1
|
||||
|
||||
| Constant | Value |
|
||||
|----------|-------|
|
||||
| viewBox | default `[720, auto]` — auto height = 52 + lanes×104 + (lanes−1)×20 + 124 |
|
||||
| Lane frame | x 40, width 640, height 104, gap 20; first lane top at y 52 |
|
||||
| Lane title strip | top 30px of each lane; node boxes must stay below it |
|
||||
| Column centers (`col` 0–5) | x = 88, 220, 300, 430, 500, 625 |
|
||||
| Phase headers | Optional `phases[]` render above the first lane, spanning `fromCol..toCol` |
|
||||
| Lane groups | Optional `groups[]` frame parallel work or branch work inside one lane |
|
||||
| Exception lanes | Set `lane.variant: "exception"` for retry, denial, fallback, or failure paths |
|
||||
| Main path lint | Optional `mainPath[]` checks that happy-path steps have matching edges and do not move backward |
|
||||
| Default node | 92×52 (height 68 when `tag` is set) |
|
||||
| Node spacing | ≥8px between nodes in the same lane |
|
||||
| Edge length | straight segments must span ≥28px |
|
||||
| Legend row | y = lane bottom + 44; viewBox height must be ≥ legend y + 18 |
|
||||
|
||||
Column-center gaps are 132 / 80 / 130 / 70 / 125 px: columns 1↔2 (80px) and
|
||||
3↔4 (70px) cannot both hold default-width 92px nodes in the same lane. Such an
|
||||
invalid v1 source receives one causal `workflow/column-capacity` diagnostic and
|
||||
a verified migration-to-v2 repair; v1 never falls through to adaptive layout.
|
||||
|
||||
### Readable v2
|
||||
|
||||
| Invariant | Contract |
|
||||
|----------|----------|
|
||||
| Logical columns | `col` is an integer in `0..5`; pixel centers are measured output |
|
||||
| Adjacent-rank baseline | 120px center distance before document-specific constraints |
|
||||
| Same-lane node clearance | ≥8px when vertical node intervals overlap |
|
||||
| Facing direct edge | clear gap ≥`max(28px, measured label mask width + 8px)` |
|
||||
| Automatic route rhythm | direct segment ≥28px; endpoint stub ≥8px; interior turn segment ≥16px |
|
||||
| Implicit viewBox | intrinsic content bounds plus contract padding |
|
||||
| Explicit viewBox | containment capacity; too-small input reports exact `requiredViewBox` and contributors |
|
||||
|
||||
The compiler applies constraints only to actual related or overlapping
|
||||
same-lane nodes, so a wide node in an unrelated lane does not expand every
|
||||
rank. Legacy centers are a soft preference after correctness constraints, not
|
||||
a geometry promise. Phase and group frames derive from the solved rank bands.
|
||||
Automatic routes are normalized once and the same final scene drives
|
||||
validation and SVG serialization. Long automatic labels compare direct-gutter
|
||||
growth with a legal channel instead of widening every downstream rank. Measured
|
||||
multi-row legends participate in intrinsic height and explicit viewBox
|
||||
capacity.
|
||||
|
||||
Authored `via`, `labelAt`, `channelX`, and `channelY` are absolute hard pins in
|
||||
v2; an infeasible pin returns `workflow/explicit-pin-conflict` rather than being
|
||||
silently moved. `fromSide` and `toSide` remain direction constraints. A route
|
||||
preset restricts the automatic candidate family but is not itself an absolute
|
||||
coordinate pin. When either endpoint side is omitted, the v2 compiler chooses
|
||||
a feasible side; an authored side restricts that endpoint to the named port.
|
||||
|
||||
## Design Rules
|
||||
|
||||
- Use lanes for ownership or runtime boundaries.
|
||||
- Use phase headers for high-level story beats such as Intake, Plan, Execute, and Report.
|
||||
- Use groups for parallel checks, branch handling, or bounded work within a lane; every group must contain at least one node.
|
||||
- Use `lane.variant: "exception"` for human wait, denial, retry, fallback, and failure lanes instead of mixing those paths into the happy path.
|
||||
- Set `mainPath` when the diagram has a clear happy path; the renderer validates that consecutive ids have matching edges and move left-to-right.
|
||||
- Place nodes with lane IDs and `col` indexes in `0..5`, not raw SVG coordinates.
|
||||
- Preserve semantic edge labels. Readable v2 allocates measured label clearance;
|
||||
when a label does not fit, repair the reported capacity or route constraint
|
||||
instead of deleting meaning.
|
||||
- Use labels for decisions, approvals, protocols, async traces, return paths,
|
||||
and any other relationship meaning not fully implied by its endpoints.
|
||||
- Prefer route presets — `drop` (bend between lanes; `bias` 0–1 picks where),
|
||||
`outside-right`, `return-left`, `bottom-channel`, and `up-channel` — before
|
||||
using raw `via` points. `straight` and the default `auto` cover the rest.
|
||||
- Keep workflow examples compact enough to render well in narrow chat/browser
|
||||
previews.
|
||||
|
||||
### Optional semantic checks
|
||||
|
||||
Layout validation cannot infer domain truth from labels or cards. When source
|
||||
evidence establishes roots, terminals, mandatory direct relationships, or
|
||||
mandatory directed reachability, encode those facts in `semanticChecks`:
|
||||
|
||||
```json
|
||||
"semanticChecks": {
|
||||
"allowedRoots": ["request", "resource_catalog"],
|
||||
"allowedTerminals": ["reply", "audit_log"],
|
||||
"requiredEdges": [
|
||||
{ "from": "dispatch", "to": "dispatch_ledger" }
|
||||
],
|
||||
"requiredPaths": [
|
||||
{ "from": "event_ledger", "to": "runtime_host" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
When `allowedRoots` or `allowedTerminals` is present, it is the complete allow
|
||||
list for zero-incoming or zero-outgoing nodes respectively. `requiredEdges`
|
||||
requires one exact authored direction; `requiredPaths` permits intermediate
|
||||
nodes but follows authored edge direction. These checks run before layout, do
|
||||
not alter SVG or receipt bytes, and must not be weakened merely to resolve a
|
||||
route or composition diagnostic. Omit fields whose domain facts are unknown.
|
||||
|
||||
Schema violations exit non-zero with path-prefixed messages annotated with the
|
||||
element's id or label. The renderer additionally fails when it can detect
|
||||
layout problems, including node overlap, nodes outside their lanes, invalid
|
||||
phase/group column ranges, empty groups, broken `mainPath` steps, unknown edge
|
||||
targets, labels colliding with nodes or other labels, labels wider than their
|
||||
node, legends outside the viewBox, or straight arrows that are too short to
|
||||
read cleanly. The shared Clean Flow Gate also rejects edges crossing unrelated
|
||||
nodes with 2px clearance; lanes, phases, and groups remain intentional
|
||||
pass-through containers. Text width is estimated CJK-aware: fullwidth glyphs
|
||||
count as two units.
|
||||
|
||||
Diagnostics are causal: a rank-capacity failure suppresses derivative short
|
||||
edge, endpoint-direction, and label-overlap findings. Every
|
||||
`supportedFixes[]` entry is verified by replanning the proposed edit, and a
|
||||
diagnostic never proposes removing a semantic label when label presence does
|
||||
not cause the failed invariant.
|
||||
|
||||
Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
|
||||
X crossings then fail with `composition/proper-crossing`; default `standard`
|
||||
keeps them as artifact-receipt warnings. Collinear lane corridors are outside
|
||||
the proper-X rule, but a separate gate warns in `standard` and fails in
|
||||
`showcase` when unrelated edges overlap for at least 8px. Shared semantic
|
||||
endpoints, point touches, and shorter overlaps remain valid. Showcase also
|
||||
rejects any route segment below 8px and any interior turn segment below 16px;
|
||||
ordinary 8–15px endpoint stubs remain valid for fixed lane gaps.
|
||||
@@ -0,0 +1,35 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadDiagramWithBrandMarks, writeDiagram } from '../shared/cli.mjs';
|
||||
import { throwDiagnosticError } from '../shared/diagnostics.mjs';
|
||||
import { compileWorkflow } from './workflow-compiler.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const { diagram: workflow, template, outPath } = await loadDiagramWithBrandMarks({
|
||||
rendererDir: __dirname,
|
||||
diagramType: 'workflow',
|
||||
defaultExample: 'agent-tool-call.workflow.json'
|
||||
});
|
||||
|
||||
const compiled = compileWorkflow({
|
||||
workflow,
|
||||
qualityProfile: process.env.ARCHIFY_QUALITY_PROFILE || workflow.meta?.quality_profile,
|
||||
});
|
||||
|
||||
const layoutJson = process.argv.includes('--layout-json');
|
||||
|
||||
if (layoutJson) {
|
||||
process.stdout.write(`${JSON.stringify(compiled.receipt, null, 2)}\n`);
|
||||
if (!compiled.ok) process.exitCode = 1;
|
||||
} else if (!compiled.ok) {
|
||||
throwDiagnosticError(compiled.error || 'Workflow compilation failed.', compiled.diagnostics);
|
||||
} else {
|
||||
writeDiagram({
|
||||
outPath,
|
||||
template,
|
||||
diagramType: 'workflow',
|
||||
meta: workflow.meta,
|
||||
svg: compiled.svg,
|
||||
cards: workflow.cards,
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
const TARGET_SCHEMA_VERSION = 2;
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the authored workflow as a schema-v2 document without its capacity
|
||||
* override. The compiler can use this projection to discover the intrinsic v2
|
||||
* rank plan before deciding whether an explicit viewBox needs to grow.
|
||||
*/
|
||||
export function intrinsicWorkflow(workflow) {
|
||||
const intrinsic = clone(workflow);
|
||||
intrinsic.schema_version = TARGET_SCHEMA_VERSION;
|
||||
intrinsic.meta = { ...intrinsic.meta };
|
||||
delete intrinsic.meta.viewBox;
|
||||
return intrinsic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a schema-v2 planning projection that removes authored route geometry
|
||||
* which may only become valid after its legacy X coordinates are remapped.
|
||||
* Rank-affecting automatic and straight relationships remain in the projection.
|
||||
*/
|
||||
export function planningWorkflow(workflow) {
|
||||
const planned = intrinsicWorkflow(workflow);
|
||||
planned.edges = planned.edges.flatMap((edge) => {
|
||||
const hasRoutedGeometry = Array.isArray(edge.via)
|
||||
|| (edge.route && !['auto', 'straight'].includes(edge.route))
|
||||
|| edge.channelX !== undefined
|
||||
|| edge.channelY !== undefined;
|
||||
if (hasRoutedGeometry) return [];
|
||||
|
||||
const automatic = {};
|
||||
for (const property of ['id', 'from', 'to', 'variant', 'role', 'width']) {
|
||||
if (edge[property] !== undefined) automatic[property] = edge[property];
|
||||
}
|
||||
if (edge.route === 'straight') automatic.route = 'straight';
|
||||
if (edge.labelAt === undefined && edge.label !== undefined) automatic.label = edge.label;
|
||||
return [automatic];
|
||||
});
|
||||
|
||||
if (Array.isArray(planned.mainPath)) {
|
||||
const projectedPairs = new Set(planned.edges.map((edge) => `${edge.from}\u0000${edge.to}`));
|
||||
const projectionBreaksMainPath = planned.mainPath.some((from, index) => (
|
||||
index < planned.mainPath.length - 1
|
||||
&& !projectedPairs.has(`${from}\u0000${planned.mainPath[index + 1]}`)
|
||||
));
|
||||
if (projectionBreaksMainPath) delete planned.mainPath;
|
||||
}
|
||||
|
||||
return planned;
|
||||
}
|
||||
|
||||
function mappedNumber(value) {
|
||||
return Number(value.toFixed(6));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a deterministic piecewise-linear mapping between corresponding legacy
|
||||
* and readable rank centers. Coordinates outside the rank span are extrapolated
|
||||
* using the nearest segment so explicitly authored outside corridors retain
|
||||
* their relative offset.
|
||||
*/
|
||||
export function createHorizontalRankMapper(oldColumns, newColumns) {
|
||||
if (
|
||||
!Array.isArray(oldColumns)
|
||||
|| !Array.isArray(newColumns)
|
||||
|| oldColumns.length !== newColumns.length
|
||||
|| oldColumns.length < 2
|
||||
|| !oldColumns.every(Number.isFinite)
|
||||
|| !newColumns.every(Number.isFinite)
|
||||
) {
|
||||
throw new TypeError('Horizontal rank mapping requires matching finite column arrays.');
|
||||
}
|
||||
for (let index = 1; index < oldColumns.length; index += 1) {
|
||||
if (oldColumns[index] <= oldColumns[index - 1] || newColumns[index] <= newColumns[index - 1]) {
|
||||
throw new TypeError('Horizontal rank mapping requires strictly increasing columns.');
|
||||
}
|
||||
}
|
||||
|
||||
return (x) => {
|
||||
if (!Number.isFinite(x)) throw new TypeError('Horizontal rank mapping requires a finite x coordinate.');
|
||||
let segment = oldColumns.length - 2;
|
||||
if (x <= oldColumns[0]) {
|
||||
segment = 0;
|
||||
} else {
|
||||
for (let index = 0; index < oldColumns.length - 1; index += 1) {
|
||||
if (x <= oldColumns[index + 1]) {
|
||||
segment = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const oldSpan = oldColumns[segment + 1] - oldColumns[segment];
|
||||
const newSpan = newColumns[segment + 1] - newColumns[segment];
|
||||
const ratio = (x - oldColumns[segment]) / oldSpan;
|
||||
return mappedNumber(newColumns[segment] + ratio * newSpan);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one horizontal coordinate mapping to every schema-v1 absolute X pin.
|
||||
* The caller owns the supplied workflow; this function reports an audit trail
|
||||
* for each changed coordinate in stable document order.
|
||||
*/
|
||||
export function mapExplicitCoordinates(workflow, mapX) {
|
||||
const changedCoordinates = [];
|
||||
const record = (path, owner, property) => {
|
||||
const from = owner[property];
|
||||
const to = mapX(from);
|
||||
owner[property] = to;
|
||||
if (to !== from) changedCoordinates.push({ path, from, to });
|
||||
};
|
||||
|
||||
for (const [edgeIndex, edge] of workflow.edges.entries()) {
|
||||
if (Array.isArray(edge.via)) {
|
||||
for (const [pointIndex, point] of edge.via.entries()) {
|
||||
if (Array.isArray(point) && Number.isFinite(point[0])) {
|
||||
record(`/edges/${edgeIndex}/via/${pointIndex}/0`, point, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(edge.labelAt) && Number.isFinite(edge.labelAt[0])) {
|
||||
record(`/edges/${edgeIndex}/labelAt/0`, edge.labelAt, 0);
|
||||
}
|
||||
if (Number.isFinite(edge.channelX)) {
|
||||
record(`/edges/${edgeIndex}/channelX`, edge, 'channelX');
|
||||
}
|
||||
}
|
||||
return changedCoordinates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct an independently owned schema-v2 candidate with all authored
|
||||
* absolute X pins mapped to the readable rank plan.
|
||||
*/
|
||||
export function createMappedWorkflowCandidate(workflow, oldColumns, newColumns) {
|
||||
const document = clone(workflow);
|
||||
document.schema_version = TARGET_SCHEMA_VERSION;
|
||||
const mapX = createHorizontalRankMapper(oldColumns, newColumns);
|
||||
const changedCoordinates = mapExplicitCoordinates(document, mapX);
|
||||
return { document, changedCoordinates };
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
# Archify JSON IR Schemas
|
||||
|
||||
Each typed renderer consumes a JSON intermediate representation (IR) validated
|
||||
against one of the schemas in this folder before any layout work happens.
|
||||
|
||||
## Files
|
||||
|
||||
| Schema | Governs | Structural arrays |
|
||||
|--------|---------|-------------------|
|
||||
| `workflow.schema.json` | `diagram_type: "workflow"` | `lanes`, `phases`, `groups`, `mainPath`, `nodes`, `edges` |
|
||||
| `sequence.schema.json` | `diagram_type: "sequence"` | `participants`, `segments`, `messages`, `activations` |
|
||||
| `dataflow.schema.json` | `diagram_type: "dataflow"` | `stages`, `nodes`, `flows` |
|
||||
| `lifecycle.schema.json` | `diagram_type: "lifecycle"` | `lanes`, `states`, `transitions` |
|
||||
| `architecture.schema.json` | `diagram_type: "architecture"` | `components`, `boundaries`, `connections` |
|
||||
| `common.schema.json` | shared `$defs` only (no top-level document) | — |
|
||||
|
||||
Every diagram schema requires `schema_version`, `diagram_type`, `meta` (with
|
||||
`title`), and its structural arrays — except `segments`, `activations`, and
|
||||
`cards`, which are optional — and sets `additionalProperties: false` at every
|
||||
level, so unknown fields are rejected rather than silently ignored.
|
||||
|
||||
Every `meta` object also accepts `animation: "trace"` for opt-in SVG/CSS motion
|
||||
in generated HTML. Omit it, or set `"none"`, for the default static output.
|
||||
It also accepts `locale: "en" | "zh-CN"`. The field selects the fixed Viewer
|
||||
UI, renderer-owned default legend and accessibility copy, document-title
|
||||
suffix, and `<html lang>` value; it does not translate authored strings.
|
||||
Omitting it preserves legacy behavior and resolves to English. Unsupported
|
||||
locale values fail schema validation instead of being guessed or silently
|
||||
rewritten.
|
||||
`visual_preset` accepts `classic` (the stable default), `signal-flow` (luminous
|
||||
motion-forward presentation), `blueprint` (high-contrast engineering review),
|
||||
or `editorial` (warm publication-style design review and documentation).
|
||||
Presets change only viewer styling; they do not alter semantic IDs or geometry.
|
||||
Sequence `meta` additionally accepts `column_fit`. The default `fixed` keeps
|
||||
the historical 108px column gap and 86px participant boxes, so an authored
|
||||
diagram renders at the same coordinates no matter how wide its viewBox is.
|
||||
`spread` derives the gap and box width from the viewBox instead, which turns a
|
||||
wide canvas into column distance and label room rather than empty space on the
|
||||
right. Lane order, IDs, and message semantics are unchanged either way.
|
||||
|
||||
It may also include up to five guided `views`. Each view has a unique `id`, a
|
||||
reader-facing `label`, a non-empty `focus` list of existing semantic node IDs,
|
||||
and an optional short `note`.
|
||||
|
||||
### Legend presentation contract
|
||||
|
||||
Every `meta` object accepts the same optional legend shape without changing
|
||||
the schema version already selected for that renderer:
|
||||
|
||||
```json
|
||||
"legend": {
|
||||
"mode": "auto",
|
||||
"entries": {
|
||||
"security": { "label": "restricted data", "visible": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`mode` is `auto` (the default), `all`, or `hidden`. `auto` includes only kinds
|
||||
present in typed IR; `all` includes the renderer's full stable catalog;
|
||||
`hidden` removes the complete legend and takes precedence over entry overrides.
|
||||
Architecture documents that omit an explicit `viewBox` size that automatic
|
||||
viewBox from the same measured resolved legend footprint used for final SVG
|
||||
layout. Across all renderers, legacy documents that omit `meta.legend` use a
|
||||
compatibility-safe implicit `auto`: if the resolved legend cannot fit an
|
||||
explicit authored viewBox without overlap, Archify omits the complete legend
|
||||
instead of turning a previously valid schema-v1 document into a hard failure.
|
||||
Once an author adds `meta.legend` (including explicit `mode: "auto"`), the
|
||||
layout is intentional and unfit labels or bands fail with a path-prefixed
|
||||
diagnostic. An entry may set a non-empty, bounded `label`, boolean `visible`,
|
||||
or both.
|
||||
`visible: false` removes a resolved entry and `visible: true` forces a supported
|
||||
but unused kind into the visual legend. Unknown kinds and properties fail
|
||||
strict validation.
|
||||
|
||||
Supported keys are renderer-owned:
|
||||
|
||||
| Renderer | `meta.legend.entries` keys |
|
||||
|---|---|
|
||||
| Architecture | `frontend`, `backend`, `database`, `cloud`, `security`, `messagebus`, `external` |
|
||||
| Workflow | `frontend`, `backend`, `security`, `messagebus`, `database`, `cloud`, `external` |
|
||||
| Sequence | `emphasis`, `return`, `security`, `dashed`, `default` |
|
||||
| Dataflow | `emphasis`, `security`, `dashed`, `database`, `default` |
|
||||
| Lifecycle | `start`, `active`, `waiting`, `decision`, `success`, `failure`, `neutral`, `external` |
|
||||
|
||||
Labels are presentation only: they do not rename the stable kind, change
|
||||
nodes/relationships, or create Semantic Lens edge facts. Sequence message and
|
||||
Dataflow flow-variant entries are visual keys. Component/state entries backed
|
||||
by exact compiled node facts receive the interactive Semantic Legend bridge;
|
||||
this includes Dataflow `database` when a real `nodes[].type: "database"` fact
|
||||
exists.
|
||||
|
||||
Every relationship collection (`connections`, `edges`, `messages`, `flows`, and
|
||||
`transitions`) accepts an optional author-controlled `id` using the shared ID
|
||||
pattern. The renderer keeps its source-order runtime key separately, while the
|
||||
authored ID enables a stable `#relation=<id>` viewer link that survives array
|
||||
reordering. ID-less documents remain valid and their relationship pins stay
|
||||
local to the current page.
|
||||
|
||||
Every semantic node collection (`components`, `nodes`, `participants`, and
|
||||
`states`) also accepts one optional `brand`: either a canonical string returned
|
||||
by `archify brands --json`, or a digest-pinned `{ "url", "sha256" }` object
|
||||
returned by `archify brands capture <url> --json`. Known IDs and known-brand
|
||||
domains use the bundled vector catalogue. Unknown URLs must be captured in that
|
||||
explicit command before authoring; render and validate never perform an
|
||||
unpinned network capture. Unsafe, unavailable, changed, or unsupported content
|
||||
fails closed with a brand diagnostic. Omitted `brand` preserves the prior
|
||||
output.
|
||||
|
||||
## schema_version policy
|
||||
|
||||
Workflow supports schema versions 1 and 2. Version 1 remains the fixed-layout
|
||||
compatibility contract; version 2 opts into the readable workflow compiler and
|
||||
can be produced explicitly with `archify migrate workflow ... --to-schema 2`.
|
||||
The other four diagram schemas keep `schema_version` pinned to `1`.
|
||||
|
||||
Workflow also accepts optional `semanticChecks`. `allowedRoots` and
|
||||
`allowedTerminals` close the set of intentional graph sources and sinks;
|
||||
`requiredEdges` requires exact authored relationships; and `requiredPaths`
|
||||
requires directed reachability while allowing intermediate nodes. The compiler
|
||||
evaluates these facts before layout and returns typed `workflow/*` diagnostics.
|
||||
The field is additive and geometry-neutral: omitting it preserves existing
|
||||
workflow behavior and including a satisfied contract does not change SVG or
|
||||
layout-receipt bytes.
|
||||
|
||||
A file that validates today must keep validating and rendering within its
|
||||
declared version throughout the 2.x release line. Additive viewer,
|
||||
accessibility, and presentation improvements may enhance generated HTML, but
|
||||
they must not reinterpret authored IR or turn a previously valid profile-less
|
||||
v1 file into a new hard layout failure. Breaking IR changes require a new
|
||||
version; additive, backwards-compatible fields do not.
|
||||
|
||||
## Shared definitions (common.schema.json)
|
||||
|
||||
The five diagram schemas reference `common.schema.json#/$defs/...`:
|
||||
|
||||
- `id` — element identifiers, pattern `^[a-zA-Z][a-zA-Z0-9_-]*$`
|
||||
- `point` — an `[x, y]` pair of numbers (used by `via` and `labelAt`)
|
||||
- `componentType` — `frontend`, `backend`, `database`, `cloud`, `security`,
|
||||
`messagebus`, `external`
|
||||
- `locale` — the bounded renderer locale, `en` or `zh-CN`
|
||||
- `brandMark` — one optional built-in brand ID or explicit HTTP(S) site URL
|
||||
- `variant` — `default`, `emphasis`, `security`, `dashed` (sequence messages
|
||||
extend this list locally with `return`)
|
||||
- `legendMode` and `legendEntry` — the shared strict mode and label/visibility
|
||||
override shapes used by each renderer-owned key map
|
||||
- `guidedViews` — the bounded, read-only reader paths accepted by `meta.views`
|
||||
- `cards` — the summary-card blocks rendered below the SVG
|
||||
|
||||
Lifecycle state `type` is mode-specific (`start`/`active`/`waiting`/...) and
|
||||
stays in `lifecycle.schema.json`.
|
||||
|
||||
## Runtime validation
|
||||
|
||||
At development time, `scripts/generate-validators.mjs` compiles all five
|
||||
schemas with ajv's draft 2020-12 standalone generator using `strict: true` and
|
||||
`allErrors: true`. The generated `renderers/shared/generated-validators.mjs`
|
||||
is committed and shipped with the skill, so runtime validation has no npm or
|
||||
network dependency. `renderers/shared/validator.mjs` applies the matching
|
||||
standalone validator before the renderer's own layout checks.
|
||||
The shared loader then checks cross-collection facts that JSON Schema cannot
|
||||
express cleanly here: duplicate view IDs, duplicate focus IDs, focus IDs that do
|
||||
not exist in the diagram's semantic collection, and duplicate authored
|
||||
relationship IDs within the mode's relationship collection.
|
||||
|
||||
Architecture additionally supports opt-in, revision-pinned repository evidence.
|
||||
`meta.repository` names a public GitHub URL and full commit SHA; a component may
|
||||
carry one to three `sources` with repo-relative POSIX paths, optional line
|
||||
ranges, and optional labels. Shape is schema-checked, then the renderer requires
|
||||
`--repo-root`: the local Git origin must match, and Git must prove the commit,
|
||||
blobs, and requested lines. Verified evidence is embedded outside the canonical
|
||||
SVG for the Semantic Passport and Node Finder; ordinary documents and visual
|
||||
exports carry no repository evidence.
|
||||
|
||||
## Visual quality and engineering truth
|
||||
|
||||
`meta.quality_profile` and `meta.engineering_profile` answer different
|
||||
questions. `quality_profile` is available in all five modes and controls how
|
||||
strictly Archify judges composition. `engineering_profile` is an optional
|
||||
Architecture-only semantic contract; omitting it preserves the ordinary v1
|
||||
behavior.
|
||||
|
||||
The first engineering profile is `deployment-ownership`. Enable it only when
|
||||
the user wants a fail-closed deployment review and the source facts are known.
|
||||
It requires every non-external component to name an owner in `tag` and belong
|
||||
to exactly one `region`; the document must contain both `region` and
|
||||
`security-group` boundaries; every `database` must be inside a
|
||||
`security-group`; each security group must contain members from one shared
|
||||
region; and every connection whose region or security-group membership changes
|
||||
must name the real crossing mechanism in `label`.
|
||||
|
||||
The profile validates only authored IR. It does not discover infrastructure,
|
||||
infer owners, or prove that a diagram matches a live environment. If a fact is
|
||||
unknown, leave the profile unset or obtain the fact instead of inventing it.
|
||||
|
||||
`npm test` runs the generator in check mode and fails when the committed
|
||||
validators drift from their schemas.
|
||||
|
||||
## Error format
|
||||
|
||||
Schema violations exit non-zero. Each ajv error is reported on its own line as
|
||||
the instance path — annotated with the nearest enclosing element's `id` or
|
||||
`label` — followed by the message and parameters:
|
||||
|
||||
```text
|
||||
workflow schema validation failed:
|
||||
/nodes/3 (id/label: "router") must NOT have additional properties {"additionalProperty":"colour"}
|
||||
```
|
||||
|
||||
Schemas catch shape errors (types, enums, ranges, unknown fields); geometry
|
||||
problems such as overlaps and label collisions are the renderers' job.
|
||||
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/tt-a1i/archify/schemas/architecture.schema.json",
|
||||
"title": "Archify Architecture Diagram",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "diagram_type", "meta", "components"],
|
||||
"properties": {
|
||||
"schema_version": { "const": 1 },
|
||||
"diagram_type": { "const": "architecture" },
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["title"],
|
||||
"properties": {
|
||||
"title": { "type": "string", "minLength": 1 },
|
||||
"locale": { "$ref": "common.schema.json#/$defs/locale" },
|
||||
"subtitle": { "type": "string" },
|
||||
"output": { "type": "string" },
|
||||
"animation": { "$ref": "common.schema.json#/$defs/animation" },
|
||||
"visual_preset": { "$ref": "common.schema.json#/$defs/visualPreset" },
|
||||
"quality_profile": { "$ref": "common.schema.json#/$defs/qualityProfile" },
|
||||
"engineering_profile": { "enum": ["deployment-ownership"] },
|
||||
"repository": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["url", "revision"],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"pattern": "^https://github\\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\\.git)?/?$"
|
||||
},
|
||||
"revision": { "type": "string", "pattern": "^[a-fA-F0-9]{40}$" }
|
||||
}
|
||||
},
|
||||
"views": { "$ref": "common.schema.json#/$defs/guidedViews" },
|
||||
"legend": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": { "$ref": "common.schema.json#/$defs/legendMode" },
|
||||
"entries": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"frontend": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"backend": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"database": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"cloud": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"security": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"messagebus": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"external": { "$ref": "common.schema.json#/$defs/legendEntry" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewBox": {
|
||||
"type": "array",
|
||||
"prefixItems": [
|
||||
{ "type": "number", "minimum": 320 },
|
||||
{ "type": "number", "minimum": 240 }
|
||||
],
|
||||
"items": false,
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["mode"],
|
||||
"properties": {
|
||||
"mode": { "enum": ["grid"] },
|
||||
"origin": { "$ref": "common.schema.json#/$defs/point" },
|
||||
"cols": { "type": "integer", "minimum": 1, "maximum": 12 },
|
||||
"gapX": { "type": "number", "minimum": 0 },
|
||||
"gapY": { "type": "number", "minimum": 0 },
|
||||
"cellW": { "type": "number", "minimum": 40 },
|
||||
"cellH": { "type": "number", "minimum": 24 }
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "type", "label"],
|
||||
"properties": {
|
||||
"id": { "$ref": "common.schema.json#/$defs/id" },
|
||||
"type": { "$ref": "common.schema.json#/$defs/componentType" },
|
||||
"label": { "type": "string", "minLength": 1 },
|
||||
"sublabel": { "type": "string" },
|
||||
"tag": { "type": "string" },
|
||||
"brand": { "$ref": "common.schema.json#/$defs/brandMark" },
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 3,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path"],
|
||||
"properties": {
|
||||
"path": { "type": "string", "minLength": 1, "maxLength": 240 },
|
||||
"line": { "type": "integer", "minimum": 1 },
|
||||
"end_line": { "type": "integer", "minimum": 1 },
|
||||
"label": { "type": "string", "minLength": 1, "maxLength": 48 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"row": { "type": "integer", "minimum": 0 },
|
||||
"col": { "type": "integer", "minimum": 0 },
|
||||
"pos": { "$ref": "common.schema.json#/$defs/point" },
|
||||
"size": {
|
||||
"type": "array",
|
||||
"prefixItems": [
|
||||
{ "type": "number", "exclusiveMinimum": 0 },
|
||||
{ "type": "number", "exclusiveMinimum": 0 }
|
||||
],
|
||||
"items": false,
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"boundaries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "label", "wraps"],
|
||||
"properties": {
|
||||
"kind": { "enum": ["region", "security-group"] },
|
||||
"label": { "type": "string", "minLength": 1 },
|
||||
"wraps": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "common.schema.json#/$defs/id" }
|
||||
},
|
||||
"pad": { "type": "number", "minimum": 0 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["from", "to"],
|
||||
"properties": {
|
||||
"id": { "$ref": "common.schema.json#/$defs/id" },
|
||||
"from": { "$ref": "common.schema.json#/$defs/id" },
|
||||
"to": { "$ref": "common.schema.json#/$defs/id" },
|
||||
"label": { "type": "string" },
|
||||
"variant": { "$ref": "common.schema.json#/$defs/variant" },
|
||||
"fromSide": { "$ref": "common.schema.json#/$defs/side" },
|
||||
"toSide": { "$ref": "common.schema.json#/$defs/side" },
|
||||
"route": { "enum": ["auto", "straight", "orthogonal-h", "orthogonal-v"] },
|
||||
"via": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "common.schema.json#/$defs/point" }
|
||||
},
|
||||
"labelAt": { "$ref": "common.schema.json#/$defs/point" },
|
||||
"labelDx": { "type": "number" },
|
||||
"labelDy": { "type": "number" },
|
||||
"labelSegment": { "type": "integer", "minimum": 0 },
|
||||
"width": { "$ref": "common.schema.json#/$defs/relationshipWidth" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"cards": { "$ref": "common.schema.json#/$defs/cards" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/tt-a1i/archify/schemas/common.schema.json",
|
||||
"title": "Archify Shared Definitions",
|
||||
"$defs": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$"
|
||||
},
|
||||
"locale": {
|
||||
"enum": ["en", "zh-CN"]
|
||||
},
|
||||
"animation": {
|
||||
"enum": ["trace", "none"]
|
||||
},
|
||||
"visualPreset": {
|
||||
"enum": ["classic", "signal-flow", "blueprint", "editorial"]
|
||||
},
|
||||
"qualityProfile": {
|
||||
"enum": ["standard", "showcase"]
|
||||
},
|
||||
"side": {
|
||||
"enum": ["left", "right", "top", "bottom"]
|
||||
},
|
||||
"relationshipWidth": {
|
||||
"type": "number",
|
||||
"minimum": 0.5
|
||||
},
|
||||
"point": {
|
||||
"type": "array",
|
||||
"prefixItems": [
|
||||
{ "type": "number" },
|
||||
{ "type": "number" }
|
||||
],
|
||||
"items": false,
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
},
|
||||
"componentType": {
|
||||
"enum": ["frontend", "backend", "database", "cloud", "security", "messagebus", "external"]
|
||||
},
|
||||
"brandMark": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 2048,
|
||||
"anyOf": [
|
||||
{ "maxLength": 80, "pattern": "^[^\\r\\n]+$" },
|
||||
{ "pattern": "^https?://" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["url", "sha256"],
|
||||
"properties": {
|
||||
"url": { "type": "string", "minLength": 8, "maxLength": 2048, "pattern": "^https?://" },
|
||||
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"variant": {
|
||||
"enum": ["default", "emphasis", "security", "dashed"]
|
||||
},
|
||||
"legendMode": {
|
||||
"enum": ["auto", "all", "hidden"]
|
||||
},
|
||||
"legendEntry": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"label": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"visible": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"guidedViews": {
|
||||
"type": "array",
|
||||
"maxItems": 5,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "label", "focus"],
|
||||
"properties": {
|
||||
"id": { "$ref": "#/$defs/id" },
|
||||
"label": { "type": "string", "minLength": 1, "maxLength": 48 },
|
||||
"focus": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/id" }
|
||||
},
|
||||
"note": { "type": "string", "maxLength": 140 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"cards": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["dot", "title", "items"],
|
||||
"properties": {
|
||||
"dot": { "enum": ["cyan", "emerald", "violet", "amber", "rose", "orange", "slate"] },
|
||||
"title": { "type": "string", "minLength": 1 },
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/tt-a1i/archify/schemas/dataflow.schema.json",
|
||||
"title": "Archify Data Flow Diagram",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"diagram_type",
|
||||
"meta",
|
||||
"stages",
|
||||
"nodes",
|
||||
"flows"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": 1
|
||||
},
|
||||
"diagram_type": {
|
||||
"const": "dataflow"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"locale": {
|
||||
"$ref": "common.schema.json#/$defs/locale"
|
||||
},
|
||||
"subtitle": {
|
||||
"type": "string"
|
||||
},
|
||||
"output": {
|
||||
"type": "string"
|
||||
},
|
||||
"animation": {
|
||||
"$ref": "common.schema.json#/$defs/animation"
|
||||
},
|
||||
"visual_preset": {
|
||||
"$ref": "common.schema.json#/$defs/visualPreset"
|
||||
},
|
||||
"quality_profile": {
|
||||
"$ref": "common.schema.json#/$defs/qualityProfile"
|
||||
},
|
||||
"views": {
|
||||
"$ref": "common.schema.json#/$defs/guidedViews"
|
||||
},
|
||||
"legend": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": { "$ref": "common.schema.json#/$defs/legendMode" },
|
||||
"entries": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"default": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"emphasis": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"security": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"dashed": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"database": { "$ref": "common.schema.json#/$defs/legendEntry" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewBox": {
|
||||
"type": "array",
|
||||
"prefixItems": [
|
||||
{
|
||||
"type": "number",
|
||||
"minimum": 360
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"minimum": 360
|
||||
}
|
||||
],
|
||||
"items": false,
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"stages": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"maxItems": 5,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"type",
|
||||
"label",
|
||||
"stage",
|
||||
"row"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "common.schema.json#/$defs/componentType"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sublabel": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag": {
|
||||
"type": "string"
|
||||
},
|
||||
"brand": {
|
||||
"$ref": "common.schema.json#/$defs/brandMark"
|
||||
},
|
||||
"stage": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"row": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"width": {
|
||||
"type": "number",
|
||||
"minimum": 48
|
||||
},
|
||||
"height": {
|
||||
"type": "number",
|
||||
"minimum": 36
|
||||
},
|
||||
"yOffset": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"flows": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"from",
|
||||
"to",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"from": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"to": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"classification": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"$ref": "common.schema.json#/$defs/variant"
|
||||
},
|
||||
"route": {
|
||||
"enum": [
|
||||
"auto",
|
||||
"straight",
|
||||
"vertical-channel",
|
||||
"bottom-channel",
|
||||
"top-channel"
|
||||
]
|
||||
},
|
||||
"fromSide": {
|
||||
"$ref": "common.schema.json#/$defs/side"
|
||||
},
|
||||
"toSide": {
|
||||
"$ref": "common.schema.json#/$defs/side"
|
||||
},
|
||||
"channelX": {
|
||||
"type": "number"
|
||||
},
|
||||
"channelY": {
|
||||
"type": "number"
|
||||
},
|
||||
"labelAt": {
|
||||
"$ref": "common.schema.json#/$defs/point"
|
||||
},
|
||||
"labelDx": {
|
||||
"type": "number"
|
||||
},
|
||||
"labelDy": {
|
||||
"type": "number"
|
||||
},
|
||||
"labelSegment": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"via": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/point"
|
||||
}
|
||||
},
|
||||
"width": {
|
||||
"$ref": "common.schema.json#/$defs/relationshipWidth"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cards": {
|
||||
"$ref": "common.schema.json#/$defs/cards"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/tt-a1i/archify/schemas/lifecycle.schema.json",
|
||||
"title": "Archify Lifecycle Diagram",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"diagram_type",
|
||||
"meta",
|
||||
"lanes",
|
||||
"states",
|
||||
"transitions"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": 1
|
||||
},
|
||||
"diagram_type": {
|
||||
"const": "lifecycle"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"locale": {
|
||||
"$ref": "common.schema.json#/$defs/locale"
|
||||
},
|
||||
"subtitle": {
|
||||
"type": "string"
|
||||
},
|
||||
"output": {
|
||||
"type": "string"
|
||||
},
|
||||
"animation": {
|
||||
"$ref": "common.schema.json#/$defs/animation"
|
||||
},
|
||||
"visual_preset": {
|
||||
"$ref": "common.schema.json#/$defs/visualPreset"
|
||||
},
|
||||
"quality_profile": {
|
||||
"$ref": "common.schema.json#/$defs/qualityProfile"
|
||||
},
|
||||
"views": {
|
||||
"$ref": "common.schema.json#/$defs/guidedViews"
|
||||
},
|
||||
"legend": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": { "$ref": "common.schema.json#/$defs/legendMode" },
|
||||
"entries": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"start": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"active": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"waiting": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"decision": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"success": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"failure": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"neutral": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"external": { "$ref": "common.schema.json#/$defs/legendEntry" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewBox": {
|
||||
"type": "array",
|
||||
"prefixItems": [
|
||||
{
|
||||
"type": "number",
|
||||
"minimum": 420
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"minimum": 566
|
||||
}
|
||||
],
|
||||
"items": false,
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"lanes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 4,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"states": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"type",
|
||||
"label",
|
||||
"lane",
|
||||
"col"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"start",
|
||||
"active",
|
||||
"waiting",
|
||||
"decision",
|
||||
"success",
|
||||
"failure",
|
||||
"neutral",
|
||||
"external"
|
||||
]
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sublabel": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag": {
|
||||
"type": "string"
|
||||
},
|
||||
"brand": {
|
||||
"$ref": "common.schema.json#/$defs/brandMark"
|
||||
},
|
||||
"step": {
|
||||
"type": "string"
|
||||
},
|
||||
"lane": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"col": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 4
|
||||
},
|
||||
"width": {
|
||||
"type": "number",
|
||||
"minimum": 48
|
||||
},
|
||||
"height": {
|
||||
"type": "number",
|
||||
"minimum": 36
|
||||
},
|
||||
"yOffset": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"transitions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"from",
|
||||
"to"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"from": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"to": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"note": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"$ref": "common.schema.json#/$defs/variant"
|
||||
},
|
||||
"route": {
|
||||
"enum": [
|
||||
"auto",
|
||||
"straight",
|
||||
"drop",
|
||||
"bottom-channel",
|
||||
"top-channel",
|
||||
"right-channel",
|
||||
"left-channel"
|
||||
]
|
||||
},
|
||||
"fromSide": {
|
||||
"$ref": "common.schema.json#/$defs/side"
|
||||
},
|
||||
"toSide": {
|
||||
"$ref": "common.schema.json#/$defs/side"
|
||||
},
|
||||
"channelX": {
|
||||
"type": "number"
|
||||
},
|
||||
"channelY": {
|
||||
"type": "number"
|
||||
},
|
||||
"cornerRadius": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"labelAt": {
|
||||
"$ref": "common.schema.json#/$defs/point"
|
||||
},
|
||||
"labelDx": {
|
||||
"type": "number"
|
||||
},
|
||||
"labelDy": {
|
||||
"type": "number"
|
||||
},
|
||||
"labelSegment": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"via": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/point"
|
||||
}
|
||||
},
|
||||
"width": {
|
||||
"$ref": "common.schema.json#/$defs/relationshipWidth"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cards": {
|
||||
"$ref": "common.schema.json#/$defs/cards"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/tt-a1i/archify/schemas/sequence.schema.json",
|
||||
"title": "Archify Sequence Diagram",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"diagram_type",
|
||||
"meta",
|
||||
"participants",
|
||||
"messages"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": 1
|
||||
},
|
||||
"diagram_type": {
|
||||
"const": "sequence"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"locale": {
|
||||
"$ref": "common.schema.json#/$defs/locale"
|
||||
},
|
||||
"subtitle": {
|
||||
"type": "string"
|
||||
},
|
||||
"output": {
|
||||
"type": "string"
|
||||
},
|
||||
"animation": {
|
||||
"$ref": "common.schema.json#/$defs/animation"
|
||||
},
|
||||
"visual_preset": {
|
||||
"$ref": "common.schema.json#/$defs/visualPreset"
|
||||
},
|
||||
"quality_profile": {
|
||||
"$ref": "common.schema.json#/$defs/qualityProfile"
|
||||
},
|
||||
"column_fit": {
|
||||
"description": "Horizontal participant layout. Omit this field or use fixed for the stable 86px boxes and 108px gap. Use spread when a wide viewBox would leave unused horizontal space or meaningful participant labels do not fit the fixed boxes; spread derives wider boxes and gaps from the viewBox without changing participant order or message semantics.",
|
||||
"enum": ["fixed", "spread"]
|
||||
},
|
||||
"views": {
|
||||
"$ref": "common.schema.json#/$defs/guidedViews"
|
||||
},
|
||||
"legend": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": { "$ref": "common.schema.json#/$defs/legendMode" },
|
||||
"entries": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"default": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"emphasis": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"security": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"dashed": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"return": { "$ref": "common.schema.json#/$defs/legendEntry" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewBox": {
|
||||
"type": "array",
|
||||
"prefixItems": [
|
||||
{
|
||||
"type": "number",
|
||||
"minimum": 480
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"minimum": 480
|
||||
}
|
||||
],
|
||||
"items": false,
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"participants": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"type",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "common.schema.json#/$defs/componentType"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sublabel": {
|
||||
"type": "string"
|
||||
},
|
||||
"brand": {
|
||||
"$ref": "common.schema.json#/$defs/brandMark"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"segments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"from",
|
||||
"to",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"from": {
|
||||
"type": "number"
|
||||
},
|
||||
"to": {
|
||||
"type": "number"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"from",
|
||||
"to",
|
||||
"y",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"from": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"to": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": 160
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"variant": {
|
||||
"enum": [
|
||||
"default",
|
||||
"emphasis",
|
||||
"security",
|
||||
"dashed",
|
||||
"return"
|
||||
]
|
||||
},
|
||||
"note": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"activations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"participant",
|
||||
"from",
|
||||
"to"
|
||||
],
|
||||
"properties": {
|
||||
"participant": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"from": {
|
||||
"type": "number"
|
||||
},
|
||||
"to": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "common.schema.json#/$defs/componentType"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cards": {
|
||||
"$ref": "common.schema.json#/$defs/cards"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/tt-a1i/archify/schemas/workflow.schema.json",
|
||||
"title": "Archify Workflow Diagram",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"diagram_type",
|
||||
"meta",
|
||||
"lanes",
|
||||
"nodes",
|
||||
"edges"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"enum": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
"diagram_type": {
|
||||
"const": "workflow"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"locale": {
|
||||
"$ref": "common.schema.json#/$defs/locale"
|
||||
},
|
||||
"subtitle": {
|
||||
"type": "string"
|
||||
},
|
||||
"output": {
|
||||
"type": "string"
|
||||
},
|
||||
"animation": {
|
||||
"enum": [
|
||||
"trace",
|
||||
"none"
|
||||
]
|
||||
},
|
||||
"visual_preset": {
|
||||
"enum": [
|
||||
"classic",
|
||||
"signal-flow",
|
||||
"blueprint",
|
||||
"editorial"
|
||||
]
|
||||
},
|
||||
"quality_profile": {
|
||||
"enum": [
|
||||
"standard",
|
||||
"showcase"
|
||||
]
|
||||
},
|
||||
"views": {
|
||||
"$ref": "common.schema.json#/$defs/guidedViews"
|
||||
},
|
||||
"legend": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": { "$ref": "common.schema.json#/$defs/legendMode" },
|
||||
"entries": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"frontend": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"backend": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"database": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"cloud": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"security": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"messagebus": { "$ref": "common.schema.json#/$defs/legendEntry" },
|
||||
"external": { "$ref": "common.schema.json#/$defs/legendEntry" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewBox": {
|
||||
"type": "array",
|
||||
"prefixItems": [
|
||||
{
|
||||
"type": "number",
|
||||
"minimum": 700
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"minimum": 240
|
||||
}
|
||||
],
|
||||
"items": false,
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"lanes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"variant": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"exception"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"phases": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"label",
|
||||
"fromCol",
|
||||
"toCol"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"fromCol": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 5
|
||||
},
|
||||
"toCol": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 5
|
||||
},
|
||||
"variant": {
|
||||
"enum": [
|
||||
"default",
|
||||
"emphasis",
|
||||
"security",
|
||||
"dashed"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"label",
|
||||
"lane",
|
||||
"fromCol",
|
||||
"toCol"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"lane": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"fromCol": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 5
|
||||
},
|
||||
"toCol": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 5
|
||||
},
|
||||
"variant": {
|
||||
"enum": [
|
||||
"default",
|
||||
"emphasis",
|
||||
"security",
|
||||
"dashed"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mainPath": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
}
|
||||
},
|
||||
"semanticChecks": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"allowedRoots": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
}
|
||||
},
|
||||
"allowedTerminals": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
}
|
||||
},
|
||||
"requiredEdges": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/semanticRelation"
|
||||
}
|
||||
},
|
||||
"requiredPaths": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/semanticRelation"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"lane",
|
||||
"col",
|
||||
"type",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"lane": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"col": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 5
|
||||
},
|
||||
"type": {
|
||||
"$ref": "common.schema.json#/$defs/componentType"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sublabel": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag": {
|
||||
"type": "string"
|
||||
},
|
||||
"brand": {
|
||||
"$ref": "common.schema.json#/$defs/brandMark"
|
||||
},
|
||||
"width": {
|
||||
"type": "number",
|
||||
"minimum": 32
|
||||
},
|
||||
"height": {
|
||||
"type": "number",
|
||||
"minimum": 32
|
||||
},
|
||||
"yOffset": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"edges": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"from",
|
||||
"to"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"from": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"to": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"$ref": "common.schema.json#/$defs/variant"
|
||||
},
|
||||
"role": {
|
||||
"enum": [
|
||||
"main",
|
||||
"branch",
|
||||
"async",
|
||||
"return",
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"fromSide": {
|
||||
"$ref": "#/$defs/side"
|
||||
},
|
||||
"toSide": {
|
||||
"$ref": "#/$defs/side"
|
||||
},
|
||||
"route": {
|
||||
"enum": [
|
||||
"auto",
|
||||
"straight",
|
||||
"drop",
|
||||
"outside-right",
|
||||
"return-left",
|
||||
"bottom-channel",
|
||||
"up-channel"
|
||||
]
|
||||
},
|
||||
"via": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/point"
|
||||
}
|
||||
},
|
||||
"labelAt": {
|
||||
"$ref": "common.schema.json#/$defs/point"
|
||||
},
|
||||
"labelDx": {
|
||||
"type": "number"
|
||||
},
|
||||
"labelDy": {
|
||||
"type": "number"
|
||||
},
|
||||
"labelSegment": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"channelX": {
|
||||
"type": "number"
|
||||
},
|
||||
"channelY": {
|
||||
"type": "number"
|
||||
},
|
||||
"bias": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"width": {
|
||||
"type": "number",
|
||||
"minimum": 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cards": {
|
||||
"$ref": "common.schema.json#/$defs/cards"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"semanticRelation": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"from",
|
||||
"to"
|
||||
],
|
||||
"properties": {
|
||||
"from": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"to": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
}
|
||||
}
|
||||
},
|
||||
"side": {
|
||||
"enum": [
|
||||
"left",
|
||||
"right",
|
||||
"top",
|
||||
"bottom"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,835 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { collectAmbiguousCorridors, collectBorderRuns, collectLabelRouteClearance, collectRouteRhythmIssues, routeBudgetMetrics } from '../renderers/shared/geometry.mjs';
|
||||
import {
|
||||
DESKTOP_READABILITY_VIEWPORT,
|
||||
DESKTOP_READER_DIAGRAM_WIDTH,
|
||||
MIN_PROJECTED_NODE_TEXT_PX,
|
||||
projectedNodeTextPx,
|
||||
} from '../renderers/shared/desktop-readability.mjs';
|
||||
|
||||
const input = process.argv[2];
|
||||
|
||||
if (!input || input === '-h' || input === '--help') {
|
||||
console.error('Usage: node scripts/check-render-output.mjs <diagram.html>');
|
||||
process.exit(input ? 0 : 2);
|
||||
}
|
||||
|
||||
const htmlPath = path.resolve(input);
|
||||
let html;
|
||||
try {
|
||||
html = fs.readFileSync(htmlPath, 'utf8');
|
||||
} catch (err) {
|
||||
console.error(JSON.stringify({
|
||||
ok: false,
|
||||
file: htmlPath,
|
||||
checks: [{ name: 'file_readable', ok: false, details: [err.message] }],
|
||||
}, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const checks = [];
|
||||
let composition = {
|
||||
schemaVersion: 1,
|
||||
profile: 'standard',
|
||||
status: 'pass',
|
||||
summary: { errors: 0, warnings: 0 },
|
||||
metrics: {
|
||||
properCrossings: 0,
|
||||
ambiguousCorridors: 0,
|
||||
containerBorderRuns: 0,
|
||||
labelRouteClearanceIssues: 0,
|
||||
minLabelRouteClearance: null,
|
||||
maxBends: 0,
|
||||
routesOverSuggestedBends: 0,
|
||||
maxStretch: null,
|
||||
routesOverSuggestedStretch: 0,
|
||||
minSegmentPx: null,
|
||||
minInteriorSegmentPx: null,
|
||||
shortSegmentCount: 0,
|
||||
shortEndpointSegmentCount: 0,
|
||||
shortInteriorSegmentCount: 0,
|
||||
microSegmentCount: 0,
|
||||
desktopReadabilityIssues: 0,
|
||||
minProjectedNodeTextPx: null,
|
||||
},
|
||||
suggestedLimits: { bendsPerRelationship: 2, stretch: 1.35, segmentPx: 16, microSegmentPx: 8 },
|
||||
issues: [],
|
||||
};
|
||||
|
||||
function addCheck(name, ok, details = []) {
|
||||
checks.push({ name, ok, details });
|
||||
}
|
||||
|
||||
const svgMatches = [...html.matchAll(/<svg\b[\s\S]*?<\/svg>/gi)];
|
||||
addCheck('single_svg', svgMatches.length === 1, [`found ${svgMatches.length} <svg> block(s)`]);
|
||||
|
||||
if (svgMatches.length === 1) {
|
||||
const svg = svgMatches[0][0];
|
||||
const svgRoot = svg.match(/<svg\b[^>]*>/i)?.[0] || '';
|
||||
const svgAttrs = parseAttrs(svgRoot);
|
||||
const qualityProfile = svgAttrs['data-quality-profile'] || 'standard';
|
||||
const qualityGatesEnforced = svgAttrs['data-quality-gates'] !== 'advisory';
|
||||
addCheck('finite_svg', !/\b(?:NaN|undefined|Infinity|-Infinity)\b/.test(svg));
|
||||
const legendStart = svg.indexOf('<!-- Legend -->');
|
||||
const beforeLegend = legendStart >= 0 ? svg.slice(0, legendStart) : svg;
|
||||
const desktopReadabilityIssue = collectDesktopReadability(svgAttrs, beforeLegend);
|
||||
const arrows = collectArrows(beforeLegend);
|
||||
const diagonal = arrows.flatMap((arrow) => diagonalStraightSegments(arrow).map((segment) => ({ arrow, ...segment })));
|
||||
addCheck(
|
||||
'orthogonal_arrows',
|
||||
diagonal.length === 0,
|
||||
diagonal.map(({ arrow, segmentIndex }) => `${arrow.kind} ${arrow.index} segment ${segmentIndex + 1}: ${arrow.raw}`),
|
||||
);
|
||||
const relationshipCrossings = collectRelationshipCrossings(arrows);
|
||||
const compositionFrames = collectCompositionFrames(beforeLegend);
|
||||
const containerBorderRuns = collectBorderRuns({
|
||||
routedRelations: arrows
|
||||
.filter((arrow) => arrow.from && arrow.to && arrow.borderSegments.length)
|
||||
.map((arrow) => ({
|
||||
relation: arrow,
|
||||
relationIndex: arrow.index,
|
||||
segments: arrow.borderSegments,
|
||||
})),
|
||||
frames: compositionFrames,
|
||||
});
|
||||
const routedRelationships = arrows
|
||||
.filter((arrow) => arrow.from && arrow.to && arrow.routePoints.length)
|
||||
.map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints }));
|
||||
const routeMetrics = routeBudgetMetrics({ routedRelations: routedRelationships });
|
||||
const routeRhythmIssues = collectRouteRhythmIssues({ routedRelations: routedRelationships });
|
||||
const ambiguousCorridors = collectAmbiguousCorridors({ routedRelations: routedRelationships });
|
||||
const relationshipLabels = collectRelationshipLabelMasks(beforeLegend, arrows);
|
||||
const labelClearanceThreshold = qualityProfile === 'showcase' ? 4 : 2;
|
||||
const labelRouteMeasurements = collectLabelRouteClearance({
|
||||
labels: relationshipLabels,
|
||||
routedRelations: arrows.map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints })),
|
||||
threshold: Number.MAX_VALUE,
|
||||
});
|
||||
const labelRouteClearance = collectLabelRouteClearance({
|
||||
labels: relationshipLabels,
|
||||
routedRelations: arrows.map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints })),
|
||||
threshold: labelClearanceThreshold,
|
||||
});
|
||||
const crossingIsError = qualityProfile === 'showcase';
|
||||
const corridorIsError = qualityProfile === 'showcase';
|
||||
const rhythmIsError = qualityProfile === 'showcase';
|
||||
const labelClearanceIsError = qualityProfile === 'showcase';
|
||||
const desktopReadabilityIsError = qualityProfile === 'showcase';
|
||||
const compositionErrors = (qualityGatesEnforced ? containerBorderRuns.length : 0)
|
||||
+ (crossingIsError ? relationshipCrossings.length : 0)
|
||||
+ (corridorIsError ? ambiguousCorridors.length : 0)
|
||||
+ (labelClearanceIsError ? labelRouteClearance.length : 0)
|
||||
+ (rhythmIsError ? routeRhythmIssues.length : 0)
|
||||
+ (desktopReadabilityIsError && desktopReadabilityIssue ? 1 : 0);
|
||||
const compositionWarnings = (qualityGatesEnforced ? 0 : containerBorderRuns.length)
|
||||
+ (crossingIsError ? 0 : relationshipCrossings.length)
|
||||
+ (corridorIsError ? 0 : ambiguousCorridors.length)
|
||||
+ (labelClearanceIsError ? 0 : labelRouteClearance.length)
|
||||
+ (rhythmIsError ? 0 : routeRhythmIssues.length)
|
||||
+ (desktopReadabilityIsError || !desktopReadabilityIssue ? 0 : 1);
|
||||
composition = {
|
||||
schemaVersion: 1,
|
||||
profile: qualityProfile,
|
||||
status: compositionErrors ? 'fail' : 'pass',
|
||||
summary: {
|
||||
errors: compositionErrors,
|
||||
warnings: compositionWarnings,
|
||||
},
|
||||
metrics: {
|
||||
properCrossings: relationshipCrossings.length,
|
||||
ambiguousCorridors: ambiguousCorridors.length,
|
||||
containerBorderRuns: containerBorderRuns.length,
|
||||
labelRouteClearanceIssues: labelRouteClearance.length,
|
||||
minLabelRouteClearance: labelRouteMeasurements.length
|
||||
? Math.round(Math.min(...labelRouteMeasurements.map((hit) => hit.clearance)) * 10) / 10
|
||||
: null,
|
||||
desktopReadabilityIssues: desktopReadabilityIssue ? 1 : 0,
|
||||
minProjectedNodeTextPx: desktopReadabilityIssue?.projectedFontPx ?? null,
|
||||
...roundedRouteMetrics(routeMetrics),
|
||||
},
|
||||
suggestedLimits: { bendsPerRelationship: 2, stretch: 1.35, segmentPx: 16, microSegmentPx: 8 },
|
||||
issues: [
|
||||
...containerBorderRuns.map((hit) => ({
|
||||
severity: qualityGatesEnforced ? 'error' : 'warning',
|
||||
code: 'composition/container-border-run',
|
||||
relationship: relationshipRecord(hit.relation),
|
||||
frame: frameRecord(hit.frame),
|
||||
side: hit.side,
|
||||
segmentIndex: hit.segmentIndex,
|
||||
overlapLength: Math.round(hit.overlapLength * 10) / 10,
|
||||
from: hit.overlapStart.map((value) => Math.round(value * 10) / 10),
|
||||
to: hit.overlapEnd.map((value) => Math.round(value * 10) / 10),
|
||||
})),
|
||||
...labelRouteClearance.map((hit) => ({
|
||||
severity: labelClearanceIsError ? 'error' : 'warning',
|
||||
code: 'composition/label-route-clearance',
|
||||
label: hit.label?.label || hit.labelRelation?.label || '',
|
||||
labelRelationship: relationshipRecord(hit.labelRelation),
|
||||
otherRelationship: relationshipRecord(hit.otherRelation),
|
||||
segmentIndex: hit.segmentIndex,
|
||||
labelRect: roundedRect(hit.rect),
|
||||
clearance: Math.round(hit.clearance * 10) / 10,
|
||||
intersectionLength: Math.round((hit.intersectionLength || 0) * 10) / 10,
|
||||
threshold: hit.threshold,
|
||||
from: hit.start.map((value) => Math.round(value * 10) / 10),
|
||||
to: hit.end.map((value) => Math.round(value * 10) / 10),
|
||||
})),
|
||||
...relationshipCrossings.map((hit) => ({
|
||||
severity: crossingIsError ? 'error' : 'warning',
|
||||
code: 'composition/proper-crossing',
|
||||
relationship: relationshipRecord(hit.left),
|
||||
otherRelationship: relationshipRecord(hit.right),
|
||||
point: hit.point.map((value) => Math.round(value * 10) / 10),
|
||||
})),
|
||||
...ambiguousCorridors.map((hit) => ({
|
||||
severity: corridorIsError ? 'error' : 'warning',
|
||||
code: 'composition/ambiguous-corridor',
|
||||
relationship: relationshipRecord(hit.left.relation),
|
||||
otherRelationship: relationshipRecord(hit.right.relation),
|
||||
segmentIndex: hit.leftSegment,
|
||||
otherSegmentIndex: hit.rightSegment,
|
||||
overlapLength: Math.round(hit.overlapLength * 10) / 10,
|
||||
from: hit.overlapStart.map((value) => Math.round(value * 10) / 10),
|
||||
to: hit.overlapEnd.map((value) => Math.round(value * 10) / 10),
|
||||
})),
|
||||
...routeRhythmIssues.map((hit) => ({
|
||||
severity: rhythmIsError ? 'error' : 'warning',
|
||||
code: hit.code,
|
||||
relationship: relationshipRecord(hit.relation),
|
||||
segmentIndex: hit.segmentIndex,
|
||||
position: hit.position,
|
||||
length: Math.round(hit.length * 10) / 10,
|
||||
from: hit.start.map((value) => Math.round(value * 10) / 10),
|
||||
to: hit.end.map((value) => Math.round(value * 10) / 10),
|
||||
})),
|
||||
...(desktopReadabilityIssue ? [{
|
||||
severity: desktopReadabilityIsError ? 'error' : 'warning',
|
||||
code: 'composition/desktop-readability',
|
||||
viewportWidth: DESKTOP_READABILITY_VIEWPORT.width,
|
||||
viewportHeight: DESKTOP_READABILITY_VIEWPORT.height,
|
||||
availableDiagramWidth: DESKTOP_READER_DIAGRAM_WIDTH,
|
||||
viewBoxWidth: desktopReadabilityIssue.viewBoxWidth,
|
||||
scale: desktopReadabilityIssue.scale,
|
||||
text: desktopReadabilityIssue.text,
|
||||
detail: desktopReadabilityIssue.detail,
|
||||
sourceFontPx: desktopReadabilityIssue.sourceFontPx,
|
||||
projectedFontPx: desktopReadabilityIssue.projectedFontPx,
|
||||
minimumProjectedFontPx: MIN_PROJECTED_NODE_TEXT_PX,
|
||||
}] : []),
|
||||
],
|
||||
};
|
||||
addCheck(
|
||||
'label_route_clearance',
|
||||
!labelClearanceIsError || labelRouteClearance.length === 0,
|
||||
labelRouteClearance.map((hit) => (
|
||||
`[composition/label-route-clearance] ${qualityProfile} label "${hit.label?.label || hit.labelRelation?.label || ''}" on ${relationshipName(hit.labelRelation)} is ${Math.round(hit.clearance * 10) / 10}px from ${relationshipName(hit.otherRelation)} segment ${hit.segmentIndex} [${formatPoint(hit.start)}] -> [${formatPoint(hit.end)}]${hit.intersectionLength > 0 ? ` with ${Math.round(hit.intersectionLength * 10) / 10}px hidden by the mask` : ''} (minimum ${hit.threshold}px) — use renderer-supported label controls (message y for sequence; otherwise labelAt, labelDx, labelDy, or labelSegment), or adjust the other relationship route/via/channel.`
|
||||
)),
|
||||
);
|
||||
addCheck(
|
||||
'relationship_crossings',
|
||||
!crossingIsError || relationshipCrossings.length === 0,
|
||||
relationshipCrossings.map((hit) => (
|
||||
`[composition/proper-crossing] ${qualityProfile} ${relationshipName(hit.left)} crosses ${relationshipName(hit.right)} at [${formatPoint(hit.point)}]`
|
||||
)),
|
||||
);
|
||||
addCheck(
|
||||
'relationship_corridors',
|
||||
!corridorIsError || ambiguousCorridors.length === 0,
|
||||
ambiguousCorridors.map((hit) => (
|
||||
`[composition/ambiguous-corridor] ${qualityProfile} ${relationshipName(hit.left.relation)} shares a ${Math.round(hit.overlapLength * 10) / 10}px corridor with ${relationshipName(hit.right.relation)} at [${formatPoint(hit.overlapStart)}] -> [${formatPoint(hit.overlapEnd)}]`
|
||||
)),
|
||||
);
|
||||
addCheck(
|
||||
'container_border_runs',
|
||||
!qualityGatesEnforced || containerBorderRuns.length === 0,
|
||||
containerBorderRuns.map((hit) => (
|
||||
`[composition/container-border-run] ${relationshipName(hit.relation)} follows ${frameName(hit.frame)} ${hit.side} border for ${Math.round(hit.overlapLength * 10) / 10}px on segment ${hit.segmentIndex} [${formatPoint(hit.overlapStart)}] -> [${formatPoint(hit.overlapEnd)}]`
|
||||
)),
|
||||
);
|
||||
addCheck(
|
||||
'route_rhythm',
|
||||
!rhythmIsError || routeRhythmIssues.length === 0,
|
||||
routeRhythmIssues.map((hit) => (
|
||||
`[${hit.code}] ${qualityProfile} ${relationshipName(hit.relation)} has a ${Math.round(hit.length * 10) / 10}px ${hit.position} segment ${hit.segmentIndex} [${formatPoint(hit.start)}] -> [${formatPoint(hit.end)}]`
|
||||
)),
|
||||
);
|
||||
|
||||
if (legendStart >= 0) {
|
||||
const legendFragment = svg.slice(legendStart);
|
||||
const legendBoxes = collectLegendBoxes(legendFragment);
|
||||
const collisions = collectLegendCollisions(arrows, legendBoxes);
|
||||
addCheck(
|
||||
'legend_clearance',
|
||||
collisions.length === 0,
|
||||
collisions.map((hit) => `${hit.arrow.kind} ${hit.arrow.index} crosses legend ${hit.box.label}`),
|
||||
);
|
||||
} else {
|
||||
addCheck('legend_clearance', true, ['no legend marker found']);
|
||||
}
|
||||
}
|
||||
|
||||
const ok = checks.every((check) => check.ok) && composition.status !== 'fail';
|
||||
console.log(JSON.stringify({ ok, file: htmlPath, checks, composition }, null, 2));
|
||||
process.exit(ok ? 0 : 1);
|
||||
|
||||
function collectArrows(fragment) {
|
||||
const arrows = [];
|
||||
let index = 0;
|
||||
|
||||
for (const tag of fragment.matchAll(/<(path|line)\b[^>]*>/gi)) {
|
||||
const raw = tag[0];
|
||||
if (!/\bclass="[^"]*\ba-(?:default|emphasis|security|dashed)\b/.test(raw)) continue;
|
||||
if (!/\bmarker-end=/.test(raw)) continue;
|
||||
const attrs = parseAttrs(raw);
|
||||
const segments = tag[1].toLowerCase() === 'line'
|
||||
? lineSegments(attrs)
|
||||
: pathSegments(attrs.d || '');
|
||||
const borderSegments = tag[1].toLowerCase() === 'line'
|
||||
? segments
|
||||
: straightPathSegments(attrs.d || '');
|
||||
arrows.push({
|
||||
kind: tag[1].toLowerCase(),
|
||||
index: index += 1,
|
||||
raw,
|
||||
segments,
|
||||
borderSegments,
|
||||
routePoints: parseRoutePoints(attrs['data-composition-points']) || (
|
||||
borderSegments.length ? [borderSegments[0].start, ...borderSegments.map((segment) => segment.end)] : []
|
||||
),
|
||||
from: attrs['data-edge-from'] || attrs['data-composition-edge-from'],
|
||||
to: attrs['data-edge-to'] || attrs['data-composition-edge-to'],
|
||||
id: attrs['data-edge-id'] || attrs['data-composition-edge-id'],
|
||||
key: attrs['data-edge-key'],
|
||||
label: attrs['data-edge-label'],
|
||||
offset: tag.index,
|
||||
});
|
||||
}
|
||||
|
||||
return arrows;
|
||||
}
|
||||
|
||||
function collectRelationshipLabelMasks(fragment, arrows) {
|
||||
const labels = [];
|
||||
for (const match of fragment.matchAll(/<g\b[^>]*\bdata-edge-(?:key|id|from)="[^"]*"[^>]*>[\s\S]*?<\/g>/gi)) {
|
||||
const group = match[0];
|
||||
const groupAttrs = parseAttrs(group.match(/<g\b[^>]*>/i)?.[0] || '');
|
||||
const rectTag = [...group.matchAll(/<rect\b[^>]*>/gi)]
|
||||
.map((item) => item[0])
|
||||
.find((tag) => /\bclass="[^"]*\bc-mask\b/.test(tag));
|
||||
if (!rectTag) continue;
|
||||
const attrs = parseAttrs(rectTag);
|
||||
const rect = {
|
||||
x: numberAttr(attrs, 'x'),
|
||||
y: numberAttr(attrs, 'y'),
|
||||
width: numberAttr(attrs, 'width'),
|
||||
height: numberAttr(attrs, 'height'),
|
||||
};
|
||||
if (![rect.x, rect.y, rect.width, rect.height].every(Number.isFinite)) continue;
|
||||
const groupStart = match.index;
|
||||
const groupEnd = groupStart + group.length;
|
||||
const containedOwner = arrows.find((arrow) => (
|
||||
arrow.offset > groupStart
|
||||
&& arrow.offset < groupEnd
|
||||
&& arrow.from === groupAttrs['data-edge-from']
|
||||
&& arrow.to === groupAttrs['data-edge-to']
|
||||
&& (!groupAttrs['data-edge-id'] || !arrow.id || arrow.id === groupAttrs['data-edge-id'])
|
||||
));
|
||||
const owner = arrows.find((arrow) => (
|
||||
groupAttrs['data-edge-key'] !== undefined && arrow.key === groupAttrs['data-edge-key']
|
||||
)) || containedOwner || arrows.find((arrow) => (
|
||||
arrow.id === groupAttrs['data-edge-id']
|
||||
&& arrow.from === groupAttrs['data-edge-from']
|
||||
&& arrow.to === groupAttrs['data-edge-to']
|
||||
));
|
||||
if (!owner) continue;
|
||||
if (owner.key === undefined && groupAttrs['data-edge-key'] !== undefined) owner.key = groupAttrs['data-edge-key'];
|
||||
if (!owner.id && groupAttrs['data-edge-id']) owner.id = groupAttrs['data-edge-id'];
|
||||
if (!owner.label && groupAttrs['data-edge-label']) owner.label = groupAttrs['data-edge-label'];
|
||||
labels.push({
|
||||
relation: owner,
|
||||
relationIndex: owner.index,
|
||||
label: groupAttrs['data-edge-label'] || '',
|
||||
rect,
|
||||
});
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
function roundedRect(rect) {
|
||||
return Object.fromEntries(Object.entries(rect).map(([key, value]) => [key, Math.round(value * 10) / 10]));
|
||||
}
|
||||
|
||||
function roundedRouteMetrics(metrics) {
|
||||
return {
|
||||
...metrics,
|
||||
maxStretch: metrics.maxStretch == null ? null : Math.round(metrics.maxStretch * 1000) / 1000,
|
||||
minSegmentPx: metrics.minSegmentPx == null ? null : Math.round(metrics.minSegmentPx * 10) / 10,
|
||||
minInteriorSegmentPx: metrics.minInteriorSegmentPx == null ? null : Math.round(metrics.minInteriorSegmentPx * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRoutePoints(value) {
|
||||
if (!value) return null;
|
||||
const points = value.split(';').map((pair) => pair.split(',').map(Number));
|
||||
return points.length >= 2 && points.every(isPoint) ? points : null;
|
||||
}
|
||||
|
||||
function collectRelationshipCrossings(arrows) {
|
||||
const relationships = arrows.filter((arrow) => arrow.from && arrow.to && arrow.segments.length);
|
||||
const crossings = [];
|
||||
for (let leftIndex = 0; leftIndex < relationships.length; leftIndex += 1) {
|
||||
const left = relationships[leftIndex];
|
||||
for (let rightIndex = leftIndex + 1; rightIndex < relationships.length; rightIndex += 1) {
|
||||
const right = relationships[rightIndex];
|
||||
if ([left.from, left.to].some((id) => id === right.from || id === right.to)) continue;
|
||||
let point = null;
|
||||
for (const leftSegment of left.segments) {
|
||||
for (const rightSegment of right.segments) {
|
||||
point = properSegmentIntersection(leftSegment.start, leftSegment.end, rightSegment.start, rightSegment.end);
|
||||
if (point) break;
|
||||
}
|
||||
if (point) break;
|
||||
}
|
||||
if (point) crossings.push({ left, right, point });
|
||||
}
|
||||
}
|
||||
return crossings;
|
||||
}
|
||||
|
||||
function relationshipName(arrow) {
|
||||
return arrow.id
|
||||
? `relationship id "${arrow.id}" ("${arrow.from}" -> "${arrow.to}")`
|
||||
: `relationship "${arrow.from}" -> "${arrow.to}"`;
|
||||
}
|
||||
|
||||
function relationshipRecord(arrow) {
|
||||
const stableIndex = Number(arrow.key);
|
||||
return {
|
||||
id: arrow.id,
|
||||
from: arrow.from,
|
||||
to: arrow.to,
|
||||
label: arrow.label || '',
|
||||
collectionIndex: Number.isInteger(stableIndex) && stableIndex >= 0 ? stableIndex : arrow.index - 1,
|
||||
artifactIndex: arrow.index,
|
||||
};
|
||||
}
|
||||
|
||||
function collectCompositionFrames(fragment) {
|
||||
const frames = [];
|
||||
for (const match of fragment.matchAll(/<(rect|path|line)\b[^>]*>/gi)) {
|
||||
const attrs = parseAttrs(match[0]);
|
||||
const kind = attrs['data-composition-frame-kind'];
|
||||
if (!kind) continue;
|
||||
const identity = attrs['data-composition-frame-id'] || frames.length;
|
||||
if (match[1].toLowerCase() === 'rect') {
|
||||
const frame = {
|
||||
kind,
|
||||
id: identity,
|
||||
x: numberAttr(attrs, 'x'),
|
||||
y: numberAttr(attrs, 'y'),
|
||||
width: numberAttr(attrs, 'width'),
|
||||
height: numberAttr(attrs, 'height'),
|
||||
radius: numberAttr(attrs, 'rx') || 0,
|
||||
};
|
||||
if ([frame.x, frame.y, frame.width, frame.height].every(Number.isFinite)) frames.push(frame);
|
||||
continue;
|
||||
}
|
||||
const segments = match[1].toLowerCase() === 'line'
|
||||
? lineSegments(attrs)
|
||||
: pathSegments(attrs.d || '');
|
||||
for (const [segmentIndex, segment] of segments.entries()) {
|
||||
frames.push({
|
||||
kind,
|
||||
id: segments.length > 1 ? `${identity}:${segmentIndex}` : identity,
|
||||
shape: 'line',
|
||||
start: segment.start,
|
||||
end: segment.end,
|
||||
});
|
||||
}
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
function frameName(frame) {
|
||||
return `${frame.kind || 'frame'} "${frame.id}"`;
|
||||
}
|
||||
|
||||
function frameRecord(frame) {
|
||||
return { kind: frame.kind, id: frame.id };
|
||||
}
|
||||
|
||||
function formatPoint(point) {
|
||||
return point.map((value) => Math.round(value * 10) / 10).join(', ');
|
||||
}
|
||||
|
||||
function lineSegments(attrs) {
|
||||
const start = [numberAttr(attrs, 'x1'), numberAttr(attrs, 'y1')];
|
||||
const end = [numberAttr(attrs, 'x2'), numberAttr(attrs, 'y2')];
|
||||
if (!isPoint(start) || !isPoint(end)) return [];
|
||||
return [{ start, end }];
|
||||
}
|
||||
|
||||
function pathSegments(d) {
|
||||
const points = pointsFromPath(d);
|
||||
const segments = [];
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
segments.push({ start: points[i - 1], end: points[i] });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
// Border runs use exact visible primitives. Non-collinear Q curves are never
|
||||
// flattened into chords here: a tangent or sampled near-horizontal curve is
|
||||
// not a structural border run. A fully collinear Q remains a straight visible
|
||||
// primitive and is included.
|
||||
function straightPathSegments(d) {
|
||||
const tokens = d.match(/[MLHVQZmlhvqz]|[-+]?(?:\d*\.)?\d+(?:e[-+]?\d+)?/g) || [];
|
||||
const segments = [];
|
||||
let i = 0;
|
||||
let command = '';
|
||||
let current = [0, 0];
|
||||
let start = null;
|
||||
while (i < tokens.length) {
|
||||
if (isCommand(tokens[i])) command = tokens[i++];
|
||||
if (!command) break;
|
||||
const absolute = command === command.toUpperCase();
|
||||
switch (command.toUpperCase()) {
|
||||
case 'M':
|
||||
case 'L': {
|
||||
let first = true;
|
||||
while (i + 1 < tokens.length && !isCommand(tokens[i])) {
|
||||
const point = [Number.parseFloat(tokens[i++]), Number.parseFloat(tokens[i++])];
|
||||
if (!point.every(Number.isFinite)) break;
|
||||
const next = absolute ? point : [current[0] + point[0], current[1] + point[1]];
|
||||
if (command.toUpperCase() === 'L' || !first) segments.push({ start: current, end: next });
|
||||
current = next;
|
||||
if (!start) start = current;
|
||||
first = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'H': {
|
||||
while (i < tokens.length && !isCommand(tokens[i])) {
|
||||
const value = Number.parseFloat(tokens[i++]);
|
||||
if (!Number.isFinite(value)) break;
|
||||
const next = [absolute ? value : current[0] + value, current[1]];
|
||||
segments.push({ start: current, end: next });
|
||||
current = next;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'V': {
|
||||
while (i < tokens.length && !isCommand(tokens[i])) {
|
||||
const value = Number.parseFloat(tokens[i++]);
|
||||
if (!Number.isFinite(value)) break;
|
||||
const next = [current[0], absolute ? value : current[1] + value];
|
||||
segments.push({ start: current, end: next });
|
||||
current = next;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Q': {
|
||||
while (i + 3 < tokens.length && !isCommand(tokens[i])) {
|
||||
const values = [0, 0, 0, 0].map(() => Number.parseFloat(tokens[i++]));
|
||||
if (!values.every(Number.isFinite)) break;
|
||||
const control = absolute ? values.slice(0, 2) : [current[0] + values[0], current[1] + values[1]];
|
||||
const end = absolute ? values.slice(2, 4) : [current[0] + values[2], current[1] + values[3]];
|
||||
if (Math.abs(crossProduct(current, control, end)) <= 1e-9) segments.push({ start: current, end });
|
||||
current = end;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Z':
|
||||
if (start) segments.push({ start: current, end: start });
|
||||
current = start || current;
|
||||
command = '';
|
||||
break;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return segments.filter(({ start: a, end: b }) => isPoint(a) && isPoint(b));
|
||||
}
|
||||
|
||||
function diagonalStraightSegments(arrow) {
|
||||
return arrow.borderSegments.flatMap(({ start, end }, segmentIndex) => (
|
||||
Math.abs(start[0] - end[0]) > 0.01 && Math.abs(start[1] - end[1]) > 0.01
|
||||
? [{ segmentIndex, start, end }]
|
||||
: []
|
||||
));
|
||||
}
|
||||
|
||||
function collectLegendBoxes(fragment) {
|
||||
const boxes = [];
|
||||
|
||||
for (const match of fragment.matchAll(/<rect\b[^>]*>/gi)) {
|
||||
const attrs = parseAttrs(match[0]);
|
||||
const x = numberAttr(attrs, 'x');
|
||||
const y = numberAttr(attrs, 'y');
|
||||
const width = numberAttr(attrs, 'width');
|
||||
const height = numberAttr(attrs, 'height');
|
||||
if ([x, y, width, height].every(Number.isFinite)) {
|
||||
boxes.push({ x1: x, y1: y, x2: x + width, y2: y + height, label: `rect@${x},${y}` });
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of fragment.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text>/gi)) {
|
||||
const attrs = parseAttrs(match[1]);
|
||||
const box = textBox(attrs, stripTags(match[2]).trim());
|
||||
if (box) boxes.push(box);
|
||||
}
|
||||
|
||||
return boxes;
|
||||
}
|
||||
|
||||
function collectLegendCollisions(arrows, boxes) {
|
||||
const collisions = [];
|
||||
for (const arrow of arrows) {
|
||||
for (const segment of arrow.segments) {
|
||||
for (const box of boxes) {
|
||||
if (segmentIntersectsBox(segment, padBox(box, 2))) {
|
||||
collisions.push({ arrow, box });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return collisions;
|
||||
}
|
||||
|
||||
function textBox(attrs, text) {
|
||||
const x = numberAttr(attrs, 'x');
|
||||
const y = numberAttr(attrs, 'y');
|
||||
const fontSize = Number.parseFloat(attrs['font-size'] || '10');
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(fontSize)) return null;
|
||||
const width = estimatedTextWidth(text, fontSize);
|
||||
const anchor = attrs['text-anchor'] || 'start';
|
||||
let x1 = x;
|
||||
if (anchor === 'middle') x1 = x - width / 2;
|
||||
if (anchor === 'end') x1 = x - width;
|
||||
return {
|
||||
x1,
|
||||
y1: y - fontSize,
|
||||
x2: x1 + width,
|
||||
y2: y + fontSize * 0.25,
|
||||
label: text || `text@${x},${y}`,
|
||||
};
|
||||
}
|
||||
|
||||
function collectDesktopReadability(svgAttrs, fragment) {
|
||||
const viewBox = String(svgAttrs.viewBox || '').trim().split(/[\s,]+/).map(Number);
|
||||
const viewBoxWidth = viewBox.length === 4 ? viewBox[2] : Number.NaN;
|
||||
if (!Number.isFinite(viewBoxWidth) || viewBoxWidth <= 0) return null;
|
||||
const scale = Math.min(1, DESKTOP_READER_DIAGRAM_WIDTH / viewBoxWidth);
|
||||
let worst = null;
|
||||
for (const match of fragment.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text>/gi)) {
|
||||
const primary = /\bdata-node-label(?:\s*=|\s|$)/i.test(match[1]);
|
||||
const boundary = /\bdata-boundary-label(?:\s*=|\s|$)/i.test(match[1]);
|
||||
const context = /\bdata-detail\s*=\s*"context"/i.test(match[1]);
|
||||
if (!primary && !boundary && !context) continue;
|
||||
const attrs = parseAttrs(match[1]);
|
||||
const fontSize = Number.parseFloat(attrs['font-size'] || '');
|
||||
if (!Number.isFinite(fontSize)) continue;
|
||||
const projected = projectedNodeTextPx(fontSize, viewBoxWidth);
|
||||
if (projected >= MIN_PROJECTED_NODE_TEXT_PX) continue;
|
||||
const candidate = {
|
||||
viewBoxWidth,
|
||||
scale,
|
||||
text: stripTags(match[2]).trim(),
|
||||
detail: primary
|
||||
? 'primary'
|
||||
: boundary ? 'boundary' : 'context',
|
||||
sourceFontPx: fontSize,
|
||||
projectedFontPx: projected,
|
||||
};
|
||||
if (!worst || candidate.projectedFontPx < worst.projectedFontPx) worst = candidate;
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
function estimatedTextWidth(text, fontSize) {
|
||||
let units = 0;
|
||||
for (const char of text) units += char.charCodeAt(0) > 255 ? 1.8 : 0.62;
|
||||
return Math.max(fontSize, units * fontSize);
|
||||
}
|
||||
|
||||
function pointsFromPath(d) {
|
||||
const tokens = d.match(/[MLHVQZmlhvqz]|[-+]?(?:\d*\.)?\d+(?:e[-+]?\d+)?/g) || [];
|
||||
const points = [];
|
||||
let i = 0;
|
||||
let command = '';
|
||||
let current = [0, 0];
|
||||
let start = null;
|
||||
|
||||
while (i < tokens.length) {
|
||||
if (isCommand(tokens[i])) command = tokens[i++];
|
||||
if (!command) break;
|
||||
|
||||
const absolute = command === command.toUpperCase();
|
||||
switch (command.toUpperCase()) {
|
||||
case 'M':
|
||||
case 'L': {
|
||||
while (i + 1 < tokens.length && !isCommand(tokens[i])) {
|
||||
const x = Number.parseFloat(tokens[i++]);
|
||||
const y = Number.parseFloat(tokens[i++]);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) break;
|
||||
current = absolute ? [x, y] : [current[0] + x, current[1] + y];
|
||||
points.push(current);
|
||||
if (!start) start = current;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'H': {
|
||||
while (i < tokens.length && !isCommand(tokens[i])) {
|
||||
const x = Number.parseFloat(tokens[i++]);
|
||||
if (!Number.isFinite(x)) break;
|
||||
current = absolute ? [x, current[1]] : [current[0] + x, current[1]];
|
||||
points.push(current);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'V': {
|
||||
while (i < tokens.length && !isCommand(tokens[i])) {
|
||||
const y = Number.parseFloat(tokens[i++]);
|
||||
if (!Number.isFinite(y)) break;
|
||||
current = absolute ? [current[0], y] : [current[0], current[1] + y];
|
||||
points.push(current);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Q': {
|
||||
while (i + 3 < tokens.length && !isCommand(tokens[i])) {
|
||||
const controlX = Number.parseFloat(tokens[i++]);
|
||||
const controlY = Number.parseFloat(tokens[i++]);
|
||||
const endX = Number.parseFloat(tokens[i++]);
|
||||
const endY = Number.parseFloat(tokens[i++]);
|
||||
if (![controlX, controlY, endX, endY].every(Number.isFinite)) break;
|
||||
const control = absolute
|
||||
? [controlX, controlY]
|
||||
: [current[0] + controlX, current[1] + controlY];
|
||||
const end = absolute
|
||||
? [endX, endY]
|
||||
: [current[0] + endX, current[1] + endY];
|
||||
const startPoint = current;
|
||||
for (let step = 1; step <= 8; step += 1) {
|
||||
const amount = step / 8;
|
||||
const remaining = 1 - amount;
|
||||
points.push([
|
||||
remaining * remaining * startPoint[0] + 2 * remaining * amount * control[0] + amount * amount * end[0],
|
||||
remaining * remaining * startPoint[1] + 2 * remaining * amount * control[1] + amount * amount * end[1],
|
||||
]);
|
||||
}
|
||||
current = end;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Z': {
|
||||
if (start) points.push(start);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return points.filter(isPoint);
|
||||
}
|
||||
|
||||
function properSegmentIntersection(a, b, c, d) {
|
||||
const abC = crossProduct(a, b, c);
|
||||
const abD = crossProduct(a, b, d);
|
||||
const cdA = crossProduct(c, d, a);
|
||||
const cdB = crossProduct(c, d, b);
|
||||
const epsilon = 1e-9;
|
||||
const opposite = (left, right) => (left > epsilon && right < -epsilon) || (left < -epsilon && right > epsilon);
|
||||
if (!opposite(abC, abD) || !opposite(cdA, cdB)) return null;
|
||||
const denominator = (a[0] - b[0]) * (c[1] - d[1]) - (a[1] - b[1]) * (c[0] - d[0]);
|
||||
if (Math.abs(denominator) < epsilon) return null;
|
||||
const ab = a[0] * b[1] - a[1] * b[0];
|
||||
const cd = c[0] * d[1] - c[1] * d[0];
|
||||
return [
|
||||
(ab * (c[0] - d[0]) - (a[0] - b[0]) * cd) / denominator,
|
||||
(ab * (c[1] - d[1]) - (a[1] - b[1]) * cd) / denominator,
|
||||
];
|
||||
}
|
||||
|
||||
function crossProduct(a, b, c) {
|
||||
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
||||
}
|
||||
|
||||
function segmentIntersectsBox(segment, box) {
|
||||
const { start, end } = segment;
|
||||
if (pointInsideBox(start, box) || pointInsideBox(end, box)) return true;
|
||||
const edges = [
|
||||
[[box.x1, box.y1], [box.x2, box.y1]],
|
||||
[[box.x2, box.y1], [box.x2, box.y2]],
|
||||
[[box.x2, box.y2], [box.x1, box.y2]],
|
||||
[[box.x1, box.y2], [box.x1, box.y1]],
|
||||
];
|
||||
return edges.some(([a, b]) => segmentsIntersect(start, end, a, b));
|
||||
}
|
||||
|
||||
function segmentsIntersect(a, b, c, d) {
|
||||
const o1 = orientation(a, b, c);
|
||||
const o2 = orientation(a, b, d);
|
||||
const o3 = orientation(c, d, a);
|
||||
const o4 = orientation(c, d, b);
|
||||
|
||||
if (o1 !== o2 && o3 !== o4) return true;
|
||||
if (o1 === 0 && onSegment(a, c, b)) return true;
|
||||
if (o2 === 0 && onSegment(a, d, b)) return true;
|
||||
if (o3 === 0 && onSegment(c, a, d)) return true;
|
||||
if (o4 === 0 && onSegment(c, b, d)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function orientation(a, b, c) {
|
||||
const value = (b[1] - a[1]) * (c[0] - b[0]) - (b[0] - a[0]) * (c[1] - b[1]);
|
||||
if (Math.abs(value) < 1e-9) return 0;
|
||||
return value > 0 ? 1 : 2;
|
||||
}
|
||||
|
||||
function onSegment(a, b, c) {
|
||||
return b[0] <= Math.max(a[0], c[0]) + 1e-9
|
||||
&& b[0] + 1e-9 >= Math.min(a[0], c[0])
|
||||
&& b[1] <= Math.max(a[1], c[1]) + 1e-9
|
||||
&& b[1] + 1e-9 >= Math.min(a[1], c[1]);
|
||||
}
|
||||
|
||||
function pointInsideBox(point, box) {
|
||||
return point[0] >= box.x1 && point[0] <= box.x2 && point[1] >= box.y1 && point[1] <= box.y2;
|
||||
}
|
||||
|
||||
function padBox(box, padding) {
|
||||
return {
|
||||
...box,
|
||||
x1: box.x1 - padding,
|
||||
y1: box.y1 - padding,
|
||||
x2: box.x2 + padding,
|
||||
y2: box.y2 + padding,
|
||||
};
|
||||
}
|
||||
|
||||
function parseAttrs(tag) {
|
||||
const attrs = {};
|
||||
for (const match of tag.matchAll(/([\w:-]+)\s*=\s*"([^"]*)"/g)) attrs[match[1]] = match[2];
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function numberAttr(attrs, name) {
|
||||
return Number.parseFloat(attrs[name]);
|
||||
}
|
||||
|
||||
function isCommand(token) {
|
||||
return /^[A-Za-z]$/.test(token);
|
||||
}
|
||||
|
||||
function isPoint(point) {
|
||||
return Array.isArray(point) && point.length === 2 && point.every(Number.isFinite);
|
||||
}
|
||||
|
||||
function stripTags(value) {
|
||||
return value.replace(/<[^>]*>/g, '');
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as simpleIcons from 'simple-icons';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..');
|
||||
const catalogPath = path.join(root, 'brand-marks', 'catalog.json');
|
||||
const outputPath = path.join(root, 'renderers', 'shared', 'generated-brand-marks.mjs');
|
||||
const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
|
||||
const simpleIconsVersion = JSON.parse(fs.readFileSync(
|
||||
path.join(root, 'node_modules', 'simple-icons', 'package.json'),
|
||||
'utf8',
|
||||
)).version;
|
||||
const simpleBySlug = new Map(Object.values(simpleIcons)
|
||||
.filter((icon) => icon && typeof icon === 'object' && icon.slug && icon.path)
|
||||
.map((icon) => [icon.slug, icon]));
|
||||
|
||||
function normalizedList(value) {
|
||||
return [...new Set((Array.isArray(value) ? value : [])
|
||||
.map((item) => String(item).trim())
|
||||
.filter(Boolean))];
|
||||
}
|
||||
|
||||
function lookupForms(value) {
|
||||
const raw = String(value ?? '').trim().toLocaleLowerCase('en-US');
|
||||
if (!raw) return [];
|
||||
return [...new Set([
|
||||
raw,
|
||||
raw.replace(/[\s_]+/g, '-'),
|
||||
raw.replace(/[\s_.-]+/g, ''),
|
||||
])];
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`brand catalog: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (catalog.schemaVersion !== 1 || !Array.isArray(catalog.marks) || catalog.marks.length === 0) {
|
||||
fail('catalog.json must contain a non-empty schemaVersion 1 marks array');
|
||||
}
|
||||
|
||||
const ids = new Set();
|
||||
const lookupKeys = new Map();
|
||||
const domains = new Map();
|
||||
const generated = catalog.marks.map((entry, index) => {
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.id || '')) fail(`marks[${index}] has an invalid id`);
|
||||
if (ids.has(entry.id)) fail(`duplicate id ${entry.id}`);
|
||||
ids.add(entry.id);
|
||||
|
||||
const aliases = normalizedList(entry.aliases);
|
||||
const entryDomains = normalizedList(entry.domains).map((domain) => domain.toLowerCase());
|
||||
for (const key of [entry.id, ...aliases]) {
|
||||
for (const form of lookupForms(key)) {
|
||||
if (lookupKeys.has(form) && lookupKeys.get(form) !== entry.id) {
|
||||
fail(`lookup key ${JSON.stringify(key)} is shared by ${lookupKeys.get(form)} and ${entry.id}`);
|
||||
}
|
||||
lookupKeys.set(form, entry.id);
|
||||
}
|
||||
}
|
||||
for (const domain of entryDomains) {
|
||||
if (domains.has(domain) && domains.get(domain) !== entry.id) {
|
||||
fail(`domain ${domain} is shared by ${domains.get(domain)} and ${entry.id}`);
|
||||
}
|
||||
domains.set(domain, entry.id);
|
||||
}
|
||||
|
||||
let mark;
|
||||
if (entry.simpleIcon) {
|
||||
const icon = simpleBySlug.get(entry.simpleIcon);
|
||||
if (!icon) fail(`${entry.id} references missing Simple Icons slug ${entry.simpleIcon}`);
|
||||
mark = {
|
||||
id: entry.id,
|
||||
title: entry.title || icon.title,
|
||||
category: entry.category,
|
||||
aliases,
|
||||
domains: entryDomains,
|
||||
viewBox: 24,
|
||||
hex: icon.hex,
|
||||
path: icon.path,
|
||||
provenance: {
|
||||
provider: 'Simple Icons',
|
||||
providerVersion: simpleIconsVersion,
|
||||
source: icon.source,
|
||||
...(icon.guidelines ? { guidelines: icon.guidelines } : {}),
|
||||
...(icon.license ? { license: icon.license } : {}),
|
||||
},
|
||||
};
|
||||
} else if (entry.custom) {
|
||||
const custom = entry.custom;
|
||||
if (!entry.title || !custom.path || !custom.source || !/^[0-9A-F]{6}$/i.test(custom.hex || '')) {
|
||||
fail(`${entry.id} custom mark requires title, path, source, and six-digit hex`);
|
||||
}
|
||||
mark = {
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
category: entry.category,
|
||||
aliases,
|
||||
domains: entryDomains,
|
||||
viewBox: custom.viewBox || 24,
|
||||
hex: custom.hex.toUpperCase(),
|
||||
path: custom.path,
|
||||
provenance: {
|
||||
provider: 'Official brand asset',
|
||||
source: custom.source,
|
||||
...(custom.guidelines ? { guidelines: custom.guidelines } : {}),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
fail(`${entry.id} must provide simpleIcon or custom`);
|
||||
}
|
||||
if (!mark.category || !mark.title) fail(`${entry.id} is missing category or title`);
|
||||
for (const form of lookupForms(mark.title)) {
|
||||
if (lookupKeys.has(form) && lookupKeys.get(form) !== entry.id) {
|
||||
fail(`title ${JSON.stringify(mark.title)} is shared by ${lookupKeys.get(form)} and ${entry.id}`);
|
||||
}
|
||||
lookupKeys.set(form, entry.id);
|
||||
}
|
||||
return mark;
|
||||
}).sort((left, right) => left.id.localeCompare(right.id));
|
||||
|
||||
const banner = `// Generated by scripts/generate-brand-marks.mjs from brand-marks/catalog.json.\n// Simple Icons ${simpleIconsVersion}. Do not edit by hand.\n`;
|
||||
const source = `${banner}export const BRAND_MARKS = Object.freeze(${JSON.stringify(generated, null, 2)});\n`;
|
||||
|
||||
if (process.argv.includes('--check')) {
|
||||
const current = fs.existsSync(outputPath)
|
||||
? fs.readFileSync(outputPath, 'utf8').replace(/\r\n?/g, '\n')
|
||||
: '';
|
||||
if (current !== source) {
|
||||
console.error('generated brand marks are stale — run npm run generate:brand-marks');
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
const temporary = `${outputPath}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temporary, source);
|
||||
fs.renameSync(temporary, outputPath);
|
||||
console.log(`generated ${path.relative(root, outputPath)} (${generated.length} marks)`);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import Ajv2020 from 'ajv/dist/2020.js';
|
||||
import standaloneCode from 'ajv/dist/standalone/index.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const schemasDir = path.join(root, 'schemas');
|
||||
const output = path.join(root, 'renderers/shared/generated-validators.mjs');
|
||||
const diagramTypes = ['workflow', 'sequence', 'dataflow', 'lifecycle', 'architecture'];
|
||||
|
||||
const ajv = new Ajv2020({
|
||||
allErrors: true,
|
||||
strict: true,
|
||||
code: { source: true, esm: true },
|
||||
});
|
||||
ajv.addSchema(JSON.parse(fs.readFileSync(path.join(schemasDir, 'common.schema.json'), 'utf8')));
|
||||
|
||||
const schemaIds = {};
|
||||
for (const type of diagramTypes) {
|
||||
const schema = JSON.parse(fs.readFileSync(path.join(schemasDir, `${type}.schema.json`), 'utf8'));
|
||||
ajv.addSchema(schema);
|
||||
schemaIds[type] = schema.$id;
|
||||
}
|
||||
|
||||
const banner = '// Generated by scripts/generate-validators.mjs. Do not edit by hand.\n';
|
||||
const ajvUcs2Import = 'require("ajv/dist/runtime/ucs2length").default';
|
||||
const inlineUcs2Length = `function ucs2length(str) {
|
||||
const len = str.length;
|
||||
let length = 0;
|
||||
let pos = 0;
|
||||
while (pos < len) {
|
||||
length += 1;
|
||||
const value = str.charCodeAt(pos++);
|
||||
if (value >= 0xd800 && value <= 0xdbff && pos < len
|
||||
&& (str.charCodeAt(pos) & 0xfc00) === 0xdc00) pos += 1;
|
||||
}
|
||||
return length;
|
||||
}`;
|
||||
let validatorCode = standaloneCode(ajv, schemaIds);
|
||||
if (!validatorCode.includes(ajvUcs2Import)) {
|
||||
throw new Error('AJV standalone output no longer contains the expected ucs2length helper');
|
||||
}
|
||||
validatorCode = validatorCode.replaceAll(ajvUcs2Import, inlineUcs2Length);
|
||||
if (validatorCode.includes('require(')) {
|
||||
throw new Error('AJV standalone output contains an unexpected runtime dependency');
|
||||
}
|
||||
const generated = `${banner}${validatorCode}\n`;
|
||||
|
||||
if (process.argv.includes('--check')) {
|
||||
const current = fs.existsSync(output)
|
||||
? fs.readFileSync(output, 'utf8').replace(/\r\n?/g, '\n')
|
||||
: '';
|
||||
if (current !== generated) {
|
||||
console.error('generated validators are stale — run npm run generate:validators');
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
const temporary = `${output}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temporary, generated);
|
||||
fs.renameSync(temporary, output);
|
||||
console.log(`generated ${path.relative(root, output)}`);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Re-render every bundled example from its JSON IR. Installed skills keep HTML
|
||||
// beside the JSON examples; the development script passes the golden directory.
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const outputRoot = path.resolve(process.argv[2] || path.join(skillRoot, 'examples'));
|
||||
|
||||
const TARGETS = [
|
||||
['workflow', 'agent-tool-call.workflow.json', 'workflow-agent-tool-call-rendered.html'],
|
||||
['sequence', 'cache-miss-request.sequence.json', 'sequence-cache-miss-request.html'],
|
||||
['dataflow', 'product-analytics.dataflow.json', 'dataflow-product-analytics.html'],
|
||||
['lifecycle', 'agent-run.lifecycle.json', 'lifecycle-agent-run.html'],
|
||||
['architecture', 'web-app.architecture.json', 'web-app-rendered.html'],
|
||||
];
|
||||
|
||||
for (const [mode, input, output] of TARGETS) {
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', input),
|
||||
path.join(outputRoot, output),
|
||||
], { stdio: 'inherit' });
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
export const SKILL_ID = 'archify';
|
||||
export const EXPECTED_REPOSITORY = 'https://github.com/tt-a1i/archify';
|
||||
export const DEFAULT_MANIFEST_URL = 'https://tt-a1i.github.io/archify/skill-updates/archify/stable.json';
|
||||
|
||||
const CONTROL_OR_BIDI = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u;
|
||||
const HEX_40 = /^[a-f0-9]{40}$/;
|
||||
const HEX_64 = /^[a-f0-9]{64}$/;
|
||||
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
||||
const UTC_SECONDS = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
||||
|
||||
export class UpdateContractError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'UpdateContractError';
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasExactKeys(value, expected) {
|
||||
return isPlainObject(value)
|
||||
&& Object.keys(value).sort().join('\0') === [...expected].sort().join('\0');
|
||||
}
|
||||
|
||||
export function parseSemver(value) {
|
||||
if (typeof value !== 'string' || value.length > 128) {
|
||||
throw new UpdateContractError(`invalid SemVer: ${JSON.stringify(value)}`);
|
||||
}
|
||||
const match = SEMVER.exec(value);
|
||||
if (!match) throw new UpdateContractError(`invalid SemVer: ${JSON.stringify(value)}`);
|
||||
const prerelease = match[4]?.split('.') ?? null;
|
||||
if (prerelease?.some((identifier) => /^\d+$/.test(identifier)
|
||||
&& identifier.length > 1 && identifier.startsWith('0'))) {
|
||||
throw new UpdateContractError(`invalid SemVer: ${JSON.stringify(value)}`);
|
||||
}
|
||||
return {
|
||||
core: match.slice(1, 4),
|
||||
prerelease,
|
||||
build: match[5]?.split('.') ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function compareNumericIdentifiers(left, right) {
|
||||
if (left.length !== right.length) return left.length < right.length ? -1 : 1;
|
||||
if (left === right) return 0;
|
||||
return left < right ? -1 : 1;
|
||||
}
|
||||
|
||||
function comparePrerelease(left, right) {
|
||||
if (left === null && right === null) return 0;
|
||||
if (left === null) return 1;
|
||||
if (right === null) return -1;
|
||||
const length = Math.max(left.length, right.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (left[index] === undefined) return -1;
|
||||
if (right[index] === undefined) return 1;
|
||||
if (left[index] === right[index]) continue;
|
||||
const leftNumeric = /^\d+$/.test(left[index]);
|
||||
const rightNumeric = /^\d+$/.test(right[index]);
|
||||
if (leftNumeric && rightNumeric) return compareNumericIdentifiers(left[index], right[index]);
|
||||
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
|
||||
return left[index] < right[index] ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function compareSemver(leftValue, rightValue) {
|
||||
const left = parseSemver(leftValue);
|
||||
const right = parseSemver(rightValue);
|
||||
for (let index = 0; index < left.core.length; index += 1) {
|
||||
const comparison = compareNumericIdentifiers(left.core[index], right.core[index]);
|
||||
if (comparison !== 0) return comparison;
|
||||
}
|
||||
return comparePrerelease(left.prerelease, right.prerelease);
|
||||
}
|
||||
|
||||
export function releaseChannelForVersion(value) {
|
||||
return parseSemver(value).prerelease ? 'development' : 'stable';
|
||||
}
|
||||
|
||||
export function isStableCoreVersion(value) {
|
||||
try {
|
||||
const parsed = parseSemver(value);
|
||||
return parsed.prerelease === null && parsed.build === null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCanonicalUtcTimestamp(value) {
|
||||
if (typeof value !== 'string' || !UTC_SECONDS.test(value)) {
|
||||
throw new UpdateContractError('publication time must use YYYY-MM-DDTHH:mm:ssZ');
|
||||
}
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp)
|
||||
|| new Date(timestamp).toISOString().replace('.000Z', 'Z') !== value) {
|
||||
throw new UpdateContractError('publication time is not a real UTC calendar instant');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function validateLocalRelease(value) {
|
||||
if (!hasExactKeys(value, [
|
||||
'schemaVersion', 'skillId', 'channel', 'version', 'source', 'updateManifestUrl',
|
||||
])
|
||||
|| value.schemaVersion !== 1
|
||||
|| value.skillId !== SKILL_ID
|
||||
|| !hasExactKeys(value.source, ['repository'])
|
||||
|| value.source.repository !== EXPECTED_REPOSITORY
|
||||
|| value.updateManifestUrl !== DEFAULT_MANIFEST_URL) {
|
||||
throw new UpdateContractError('invalid local release identity');
|
||||
}
|
||||
const expectedChannel = releaseChannelForVersion(value.version);
|
||||
if (value.channel !== expectedChannel) {
|
||||
throw new UpdateContractError('local release channel does not match its version');
|
||||
}
|
||||
return {
|
||||
schemaVersion: value.schemaVersion,
|
||||
skillId: value.skillId,
|
||||
channel: value.channel,
|
||||
version: value.version,
|
||||
source: { repository: value.source.repository },
|
||||
updateManifestUrl: value.updateManifestUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateReleaseNotesUrl(value, version) {
|
||||
if (!isStableCoreVersion(version)) {
|
||||
throw new UpdateContractError('release notes require a stable core version');
|
||||
}
|
||||
const expected = `https://github.com/tt-a1i/archify/releases/tag/v${version}`;
|
||||
if (value !== expected) {
|
||||
throw new UpdateContractError('release notes URL is outside the exact trusted release path');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function validateStableUpdateManifest(value) {
|
||||
if (!hasExactKeys(value, [
|
||||
'schemaVersion', 'skillId', 'channel', 'version', 'publishedAt', 'source',
|
||||
'artifact', 'summary', 'releaseNotes', 'severity',
|
||||
])
|
||||
|| value.schemaVersion !== 1
|
||||
|| value.skillId !== SKILL_ID
|
||||
|| value.channel !== 'stable'
|
||||
|| !isStableCoreVersion(value.version)
|
||||
|| !hasExactKeys(value.source, ['repository', 'ref', 'treeSha'])
|
||||
|| value.source.repository !== EXPECTED_REPOSITORY
|
||||
|| value.source.ref !== `v${value.version}`
|
||||
|| !HEX_40.test(value.source.treeSha)
|
||||
|| !hasExactKeys(value.artifact, ['sha256'])
|
||||
|| !HEX_64.test(value.artifact.sha256)) {
|
||||
throw new UpdateContractError('invalid immutable stable release identity');
|
||||
}
|
||||
validateCanonicalUtcTimestamp(value.publishedAt);
|
||||
if (typeof value.summary !== 'string' || value.summary.length < 1 || value.summary.length > 160
|
||||
|| CONTROL_OR_BIDI.test(value.summary)) {
|
||||
throw new UpdateContractError('invalid release summary');
|
||||
}
|
||||
validateReleaseNotesUrl(value.releaseNotes, value.version);
|
||||
if (!['normal', 'security'].includes(value.severity)) {
|
||||
throw new UpdateContractError('invalid update severity');
|
||||
}
|
||||
return {
|
||||
schemaVersion: value.schemaVersion,
|
||||
skillId: value.skillId,
|
||||
channel: value.channel,
|
||||
version: value.version,
|
||||
publishedAt: value.publishedAt,
|
||||
source: {
|
||||
repository: value.source.repository,
|
||||
ref: value.source.ref,
|
||||
treeSha: value.source.treeSha,
|
||||
},
|
||||
artifact: { sha256: value.artifact.sha256 },
|
||||
summary: value.summary,
|
||||
releaseNotes: value.releaseNotes,
|
||||
severity: value.severity,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skillId": "archify",
|
||||
"channel": "stable",
|
||||
"version": "2.16.0",
|
||||
"source": {
|
||||
"repository": "https://github.com/tt-a1i/archify"
|
||||
},
|
||||
"updateManifestUrl": "https://tt-a1i.github.io/archify/skill-updates/archify/stable.json"
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
DESKTOP_READABILITY_VIEWPORT,
|
||||
DESKTOP_READER_DIAGRAM_WIDTH,
|
||||
DESKTOP_READER_HORIZONTAL_CHROME,
|
||||
DESKTOP_READER_MIN_WIDTH,
|
||||
MIN_PROJECTED_NODE_TEXT_PX,
|
||||
minimumReadableSourceTextPx,
|
||||
projectedNodeTextPx,
|
||||
} from '../renderers/shared/desktop-readability.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets', 'template.html'), 'utf8');
|
||||
const skill = fs.readFileSync(path.join(skillRoot, 'SKILL.md'), 'utf8');
|
||||
const reader = template.slice(
|
||||
template.indexOf('Adaptive Reader Shell'),
|
||||
template.indexOf('Archify.view = (function ()'),
|
||||
);
|
||||
|
||||
test('wide desktop diagrams use one height-budgeted reader shell instead of breakpoint jumps', () => {
|
||||
assert.match(template, /max-width: var\(--archify-reader-width, 1440px\)/);
|
||||
assert.doesNotMatch(template, /@media \(min-width: 1680px\)[\s\S]{0,180}\.container/);
|
||||
assert.doesNotMatch(template, /@media \(min-width: 1920px\)[\s\S]{0,180}\.container/);
|
||||
assert.match(reader, /var WIDE_RATIO = 1\.55/);
|
||||
assert.match(reader, /var MAX_READER_WIDTH = 1920/);
|
||||
assert.match(reader, /var availableSvgHeight = Math\.max\(1, window\.innerHeight - fixedHeight\)/);
|
||||
assert.match(reader, /var desiredWidth = availableSvgHeight \* ratio \+ chrome\.diagramX/);
|
||||
assert.match(reader, /html\.style\.setProperty\('--archify-reader-width', rounded \+ 'px'\)/);
|
||||
});
|
||||
|
||||
test('desktop readability budget matches the minimum adaptive reader at 1440 by 900', () => {
|
||||
assert.deepEqual(DESKTOP_READABILITY_VIEWPORT, { width: 1440, height: 900 });
|
||||
assert.equal(DESKTOP_READER_MIN_WIDTH, 960);
|
||||
assert.equal(DESKTOP_READER_HORIZONTAL_CHROME, 30);
|
||||
assert.equal(DESKTOP_READER_DIAGRAM_WIDTH, 930);
|
||||
assert.match(reader, new RegExp(`var MIN_READER_WIDTH = ${DESKTOP_READER_MIN_WIDTH}`));
|
||||
assert.match(template, /html\[data-nav-stage-rail="true"\] body \{ padding-block: 0\.375rem; \}/);
|
||||
assert.match(template, /@media \(min-width: 768px\) and \(max-height: 1100px\)[\s\S]*?\.diagram-container \{[\s\S]*?padding: 0\.875rem;[\s\S]*?padding-bottom: calc\(0\.875rem \+ var\(--archify-nav-reserve\)\);/);
|
||||
assert.match(template, /@media \(min-width: 768px\) and \(max-height: 920px\)[\s\S]*?body \{ padding-block: 1\.25rem; \}/);
|
||||
assert.match(template, /\.diagram-container \{[\s\S]*?border: 1px solid var\(--panel-border\)/);
|
||||
});
|
||||
|
||||
test('desktop readability source floor is the inverse of the projected-size gate', () => {
|
||||
const sourceFloor = minimumReadableSourceTextPx(1376);
|
||||
assert.ok(Math.abs(sourceFloor - 8.87741935483871) < 1e-12);
|
||||
assert.ok(Math.abs(projectedNodeTextPx(sourceFloor, 1376) - MIN_PROJECTED_NODE_TEXT_PX) < 1e-12);
|
||||
assert.equal(minimumReadableSourceTextPx(DESKTOP_READER_DIAGRAM_WIDTH), MIN_PROJECTED_NODE_TEXT_PX);
|
||||
assert.equal(minimumReadableSourceTextPx(700), MIN_PROJECTED_NODE_TEXT_PX);
|
||||
assert.ok(Number.isNaN(minimumReadableSourceTextPx(0)));
|
||||
});
|
||||
|
||||
test('adaptive width preserves canonical SVG geometry and yields to specialized viewer modes', () => {
|
||||
assert.match(reader, /window\.innerWidth >= MIN_DESKTOP_WIDTH/);
|
||||
assert.match(reader, /html\.getAttribute\('data-embed'\) !== 'true'/);
|
||||
assert.match(reader, /html\.getAttribute\('data-present'\) !== 'true'/);
|
||||
assert.match(reader, /window\.matchMedia\('print'\)\.matches/);
|
||||
assert.doesNotMatch(reader, /svg\.setAttribute\(['"](?:viewBox|width|height)/);
|
||||
assert.doesNotMatch(reader, /svg\.style\.(?:width|height)/);
|
||||
assert.doesNotMatch(reader, /overflow\s*=\s*['"]hidden/);
|
||||
});
|
||||
|
||||
test('reader remeasures real content and reduces width before allowing desktop page overflow', () => {
|
||||
assert.match(reader, /document\.fonts\.ready\.then\(schedule\)/);
|
||||
assert.match(reader, /new ResizeObserver\(schedule\)/);
|
||||
assert.match(reader, /new MutationObserver\(schedule\)/);
|
||||
assert.match(reader, /document\.documentElement\.scrollHeight/);
|
||||
assert.match(reader, /lastWidth - overflow \* ratio - 4/);
|
||||
assert.match(skill, /1440×900, 1600×1000, and 1920×1080/);
|
||||
assert.match(skill, /2048×1320/);
|
||||
assert.match(skill, /Generate one responsive artifact for laptops and external displays/);
|
||||
assert.match(skill, /preserve the authored SVG\/viewBox, proportions, semantic geometry/);
|
||||
});
|
||||
|
||||
test('reader exposes an explicit stable-dimensions contract for browser evidence', () => {
|
||||
assert.match(reader, /function stableSnapshot\(\)/);
|
||||
assert.match(reader, /function whenStable\(\)/);
|
||||
assert.match(reader, /document\.fonts && document\.fonts\.ready/);
|
||||
assert.match(reader, /Math\.ceil\(document\.body\.scrollHeight\)/);
|
||||
assert.match(reader, /stableFrames >= 3/);
|
||||
assert.match(reader, /whenStable: whenStable/);
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-animation-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
const NODE_COLLECTION = {
|
||||
architecture: 'components',
|
||||
workflow: 'nodes',
|
||||
sequence: 'participants',
|
||||
dataflow: 'nodes',
|
||||
lifecycle: 'states',
|
||||
};
|
||||
|
||||
function render(mode, example, animation = 'trace', visualPreset) {
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', example), 'utf8'));
|
||||
if (animation) doc.meta = { ...doc.meta, animation };
|
||||
else delete doc.meta.animation;
|
||||
if (visualPreset) doc.meta.visual_preset = visualPreset;
|
||||
else if (visualPreset === null) delete doc.meta.visual_preset;
|
||||
const suffix = `${animation || 'static'}-${visualPreset || 'default'}`;
|
||||
const input = path.join(tmp, `${mode}-${suffix}.json`);
|
||||
const output = path.join(tmp, `${mode}-${suffix}.html`);
|
||||
fs.writeFileSync(input, JSON.stringify(doc));
|
||||
execFileSync('node', [path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`), input, output], {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function svgBlock(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('static output omits animation attributes', () => {
|
||||
const svg = svgBlock(render('workflow', CASES.workflow, null, null));
|
||||
assert.doesNotMatch(svg, /data-animation=/);
|
||||
assert.doesNotMatch(svg, /data-animate=/);
|
||||
});
|
||||
|
||||
test('classic preset remains the default for existing diagrams', () => {
|
||||
const html = render('architecture', CASES.architecture, null, null);
|
||||
assert.match(html, /<html lang="en" data-theme="dark" data-preset="classic">/);
|
||||
assert.match(svgBlock(html), /data-preset="classic"/);
|
||||
});
|
||||
|
||||
test('signal-flow preset reaches the page, SVG, and motion export surface', () => {
|
||||
const html = render('workflow', CASES.workflow, 'trace', 'signal-flow');
|
||||
assert.match(html, /<html lang="en" data-theme="dark" data-preset="signal-flow">/);
|
||||
assert.match(svgBlock(html), /data-preset="signal-flow"/);
|
||||
assert.match(html, /content: attr\(data-preset-badge-signal-flow\)/);
|
||||
assert.match(html, /data-preset-badge-signal-flow="SIGNAL FLOW"/);
|
||||
assert.match(html, /data-format="webm"/);
|
||||
assert.match(html, /data-last-motion-bytes/);
|
||||
assert.match(html, /Archify\.motion = \{ canRecord: canRecordMotion, recordWebm: recordWebm \}/);
|
||||
assert.match(html, /recorder\.requestData\(\)/);
|
||||
assert.match(html, /aria-label="Diagram view controls"/);
|
||||
assert.match(html, /Archify\.focus = \(function \(\)/);
|
||||
assert.match(html, /Archify\.view = \(function \(\)/);
|
||||
assert.match(html, /clone\.style\.removeProperty\('transform'\)/);
|
||||
assert.match(html, /clone\.removeAttribute\('data-view-scale'\)/);
|
||||
assert.match(html, /data-last-export-canonical/);
|
||||
assert.match(html, /data-last-export-error-format/);
|
||||
assert.match(html, /data-last-export-error/);
|
||||
assert.match(html, /WebM unavailable in this browser/);
|
||||
assert.match(html, /Motion capture unavailable in this browser/);
|
||||
assert.match(html, /canonicalStateClean: canonicalStateClean/);
|
||||
assert.match(html, /recordExportReceipt\('svg', blob, d\.canonicalStateClean\)/);
|
||||
});
|
||||
|
||||
test('webm renders an explicit time-varying canvas scene instead of replaying one cached SVG bitmap', () => {
|
||||
const html = render('architecture', CASES.architecture, 'trace', 'signal-flow');
|
||||
const recordBlock = html.match(/function recordWebm\(options\) \{[\s\S]*?\n var menu =/)?.[0] || '';
|
||||
|
||||
assert.match(recordBlock, /var motionScene = createMotionScene\(svg\)/);
|
||||
assert.match(recordBlock, /drawMotionFrame\(ctx, backgroundImage, motionScene, elapsed\)/);
|
||||
assert.match(recordBlock, /getPointAtLength/);
|
||||
assert.match(recordBlock, /performance\.now\(\)/);
|
||||
assert.doesNotMatch(
|
||||
recordBlock,
|
||||
/function draw\(\) \{[\s\S]*?ctx\.drawImage\(img, 0, 0, canvas\.width, canvas\.height\);[\s\S]*?requestAnimationFrame\(draw\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('blueprint preset reaches every visual surface without changing the default', () => {
|
||||
const html = render('architecture', CASES.architecture, null, 'blueprint');
|
||||
assert.match(html, /<html lang="en" data-theme="dark" data-preset="blueprint">/);
|
||||
assert.match(svgBlock(html), /data-preset="blueprint"/);
|
||||
assert.match(html, /content: attr\(data-preset-badge-blueprint\)/);
|
||||
assert.match(html, /data-preset-badge-blueprint="BLUEPRINT \/ REV 01"/);
|
||||
assert.match(html, /\[data-preset="blueprint"\]\[data-theme="dark"\]/);
|
||||
assert.match(html, /svg\[data-preset="blueprint"\] \.c-grid/);
|
||||
assert.match(html, /html\[data-preset="blueprint"\] \.guided-views/);
|
||||
assert.match(html, /html\[data-preset="blueprint"\] \.card/);
|
||||
});
|
||||
|
||||
test('blueprint preset is accepted by all five typed renderers', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example, null, 'blueprint');
|
||||
assert.match(html, /data-preset="blueprint"/, mode);
|
||||
assert.match(svgBlock(html), /data-preset="blueprint"/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('editorial preset reaches every visual surface and all five typed renderers', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example, null, 'editorial');
|
||||
assert.match(html, /<html lang="en" data-theme="dark" data-preset="editorial">/, mode);
|
||||
assert.match(svgBlock(html), /data-preset="editorial"/, mode);
|
||||
assert.match(html, /content: attr\(data-preset-badge-editorial\)/, mode);
|
||||
assert.match(html, /data-preset-badge-editorial="EDITORIAL \/ FIELD NOTE"/, mode);
|
||||
assert.match(html, /content: attr\(data-preset-badge-editorial-plate\)/, mode);
|
||||
assert.match(html, /data-preset-badge-editorial-plate="ARCHIFY \/ PLATE 04"/, mode);
|
||||
assert.match(html, /\[data-preset="editorial"\]\[data-theme="dark"\]/, mode);
|
||||
assert.match(html, /html\[data-preset="editorial"\] \.diagram-container/, mode);
|
||||
assert.match(html, /svg\[data-preset="editorial"\] \.story-trail-flow/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('all five renderers add one geometry-neutral semantic sigil per primary node', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', example), 'utf8'));
|
||||
const expected = source[NODE_COLLECTION[mode]].length;
|
||||
const staticHtml = render(mode, example, null, 'classic');
|
||||
const traceHtml = render(mode, example, 'trace', 'classic');
|
||||
const staticSvg = svgBlock(staticHtml);
|
||||
const traceSvg = svgBlock(traceHtml);
|
||||
const sigils = (svg) => [...svg.matchAll(/<g aria-hidden="true" data-semantic-sigil="[^"]+"[\s\S]*?<\/g>/g)].map((match) => match[0]);
|
||||
|
||||
assert.equal(sigils(staticSvg).length, expected, mode);
|
||||
assert.deepEqual(sigils(traceSvg), sigils(staticSvg), `${mode} trace must not change sigil geometry`);
|
||||
assert.match(staticHtml, /svg \.semantic-sigil \{/i, mode);
|
||||
assert.match(staticHtml, /svg \.s-database\s+\{ color: var\(--database-stroke\); \}/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown visual presets are rejected by schema validation', () => {
|
||||
assert.throws(
|
||||
() => render('architecture', CASES.architecture, null, 'hologram'),
|
||||
/visual_preset/,
|
||||
);
|
||||
});
|
||||
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
test(`${mode}: trace animation annotates svg, edges, and nodes`, () => {
|
||||
const svg = svgBlock(render(mode, example));
|
||||
assert.match(svg, /<svg[^>]+data-animation="trace"/);
|
||||
assert.match(svg, /data-animate="edge" style="--step:0"/);
|
||||
assert.match(svg, /data-animate="node" style="--step:0"/);
|
||||
assert.match(svg, /aria-labelledby="archify-diagram-title archify-diagram-description"/);
|
||||
assert.match(svg, /<title id="archify-diagram-title">[^<]+<\/title>/);
|
||||
assert.match(svg, /<desc id="archify-diagram-description">[^<]+<\/desc>/);
|
||||
assert.match(svg, /id="node-[^"]+" data-node-id="[^"]+"[^>]+role="button"[^>]+aria-pressed="false"/);
|
||||
assert.match(svg, /data-edge-from="[^"]+" data-edge-to="[^"]+"/);
|
||||
});
|
||||
}
|
||||
|
||||
test('semantic SVG identity is deterministic for unchanged input', () => {
|
||||
const first = svgBlock(render('workflow', CASES.workflow));
|
||||
const second = svgBlock(render('workflow', CASES.workflow));
|
||||
const hooks = (svg) => [...svg.matchAll(/(?:id="node-|data-edge-from=")[^>]+/g)].map((match) => match[0]);
|
||||
assert.deepEqual(hooks(first), hooks(second));
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,518 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
ArchitectureDeltaError,
|
||||
architectureDeltaChangeRows,
|
||||
canonicalArchitectureJson,
|
||||
compareArchitecture,
|
||||
validateArchitectureDeltaHtml,
|
||||
} from '../delta/architecture-delta.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const cli = path.join(skillRoot, 'bin/archify.mjs');
|
||||
const baseFixture = path.join(skillRoot, 'examples/checkout-platform.base.architecture.json');
|
||||
const headFixture = path.join(skillRoot, 'examples/checkout-platform.head.architecture.json');
|
||||
const checkedArtifact = path.resolve(skillRoot, '../examples/checkout-platform-delta.html');
|
||||
const checkedReceipt = path.resolve(skillRoot, '../examples/checkout-platform-delta.receipt.json');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-delta-'));
|
||||
|
||||
const read = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
const run = (args) => spawnSync(process.execPath, [cli, ...args], { cwd: skillRoot, encoding: 'utf8' });
|
||||
|
||||
test('architecture compare classifies authored facts separately from geometry and presentation', () => {
|
||||
const receipt = compareArchitecture(read(baseFixture), read(headFixture));
|
||||
assert.equal(receipt.command, 'compare');
|
||||
assert.equal(receipt.completeness, 'complete');
|
||||
assert.equal(receipt.proofLevel, 'authored');
|
||||
assert.deepEqual(receipt.summary.components, {
|
||||
added: 1,
|
||||
changed: 1,
|
||||
evidenceChanged: 0,
|
||||
removed: 1,
|
||||
moved: 1,
|
||||
});
|
||||
assert.deepEqual(receipt.summary.connections, {
|
||||
added: 1,
|
||||
changed: 2,
|
||||
removed: 1,
|
||||
rerouted: 1,
|
||||
});
|
||||
assert.equal(receipt.summary.presentationChanged, true);
|
||||
|
||||
const checkout = receipt.changes.components.find((change) => change.id === 'checkout');
|
||||
assert.equal(checkout.status, 'changed');
|
||||
assert.deepEqual(checkout.classifications, ['semantic']);
|
||||
assert.deepEqual(checkout.changedFields, ['/sublabel']);
|
||||
|
||||
const queue = receipt.changes.components.find((change) => change.id === 'queue');
|
||||
assert.equal(queue.status, 'moved');
|
||||
assert.deepEqual(queue.classifications, ['geometry']);
|
||||
assert.deepEqual(queue.changedFields, ['/pos']);
|
||||
|
||||
const authorization = receipt.changes.connections.find((change) => change.id === 'authorize-payment');
|
||||
assert.equal(authorization.status, 'changed');
|
||||
assert.deepEqual(authorization.classifications, ['geometry', 'topology']);
|
||||
assert.deepEqual(authorization.changedFields, ['/from', '/fromSide', '/toSide', '/via']);
|
||||
|
||||
assert.deepEqual(receipt.changes.connections.find((change) => change.status === 'added').classifications, ['topology']);
|
||||
|
||||
const headWithBoundary = read(headFixture);
|
||||
headWithBoundary.boundaries.push({ kind: 'region', label: 'Fraud edge', wraps: ['fraud'] });
|
||||
const boundaryReceipt = compareArchitecture(read(baseFixture), headWithBoundary);
|
||||
assert.deepEqual(boundaryReceipt.changes.boundaries.find((change) => change.label === 'Fraud edge').classifications, ['scope']);
|
||||
});
|
||||
|
||||
test('legend-only changes are presentation changes and never topology changes', () => {
|
||||
const base = read(baseFixture);
|
||||
const head = read(baseFixture);
|
||||
head.meta.legend = {
|
||||
entries: {
|
||||
security: { label: 'Trust boundary', visible: true },
|
||||
database: { visible: false },
|
||||
},
|
||||
};
|
||||
|
||||
const receipt = compareArchitecture(base, head);
|
||||
assert.equal(receipt.summary.presentationChanged, true);
|
||||
assert.deepEqual(receipt.summary.components, {
|
||||
added: 0,
|
||||
changed: 0,
|
||||
evidenceChanged: 0,
|
||||
removed: 0,
|
||||
moved: 0,
|
||||
});
|
||||
assert.deepEqual(receipt.summary.connections, {
|
||||
added: 0,
|
||||
changed: 0,
|
||||
removed: 0,
|
||||
rerouted: 0,
|
||||
});
|
||||
assert.deepEqual(receipt.changes, { components: [], connections: [], boundaries: [] });
|
||||
});
|
||||
|
||||
test('canonical architecture ignores formatting, entity order, and set-like order', () => {
|
||||
const original = read(baseFixture);
|
||||
const reordered = JSON.parse(JSON.stringify(original));
|
||||
reordered.components.reverse();
|
||||
reordered.connections.reverse();
|
||||
reordered.boundaries.reverse();
|
||||
reordered.boundaries.forEach((boundary) => boundary.wraps.reverse());
|
||||
assert.equal(canonicalArchitectureJson(reordered), canonicalArchitectureJson(original));
|
||||
});
|
||||
|
||||
test('change navigator order is exact-ID based, complete, unique, and stable', () => {
|
||||
const receipt = compareArchitecture(read(baseFixture), read(headFixture));
|
||||
const rows = architectureDeltaChangeRows(receipt);
|
||||
assert.deepEqual(rows.map((row) => row.key), [
|
||||
'component:fraud',
|
||||
'relationship:fraud-check',
|
||||
'boundary:region:Production region',
|
||||
'boundary:security-group:Checkout trust zone',
|
||||
'component:checkout',
|
||||
'relationship:authorize-payment',
|
||||
'relationship:persist-order',
|
||||
'component:queue',
|
||||
'component:cache',
|
||||
'relationship:session-read',
|
||||
'relationship:publish-order',
|
||||
]);
|
||||
assert.equal(new Set(rows.map((row) => row.key)).size, rows.length);
|
||||
assert.equal(rows.length, receipt.changes.components.length + receipt.changes.connections.length + receipt.changes.boundaries.length);
|
||||
});
|
||||
|
||||
test('exact identity fails closed instead of guessing relationships or unrelated systems', () => {
|
||||
const base = read(baseFixture);
|
||||
const missingRelationship = read(headFixture);
|
||||
delete missingRelationship.connections[0].id;
|
||||
assert.throws(
|
||||
() => compareArchitecture(base, missingRelationship),
|
||||
(error) => error instanceof ArchitectureDeltaError
|
||||
&& error.code === 'delta/relationship-id-required'
|
||||
&& error.details.paths.includes('/connections/0/id'),
|
||||
);
|
||||
|
||||
const unrelated = read(headFixture);
|
||||
unrelated.components = unrelated.components.map((component, index) => ({ ...component, id: `other${index}` }));
|
||||
unrelated.connections = [];
|
||||
unrelated.boundaries = [];
|
||||
assert.throws(
|
||||
() => compareArchitecture(base, unrelated),
|
||||
(error) => error instanceof ArchitectureDeltaError && error.code === 'delta/no-shared-component-id',
|
||||
);
|
||||
});
|
||||
|
||||
test('evidence-only component changes keep an enabled exact review contract', () => {
|
||||
const base = read(baseFixture);
|
||||
const head = read(baseFixture);
|
||||
base.components[0].sources = [{ path: 'src/entry.js', line: 1, label: 'baseline' }];
|
||||
head.components[0].sources = [{ path: 'src/entry.js', line: 2, label: 'head' }];
|
||||
const receipt = compareArchitecture(base, head);
|
||||
assert.equal(receipt.changes.components.length, 1);
|
||||
assert.equal(receipt.changes.components[0].status, 'evidence-changed');
|
||||
assert.deepEqual(receipt.changes.components[0].classifications, ['evidence']);
|
||||
const runtime = fs.readFileSync(path.join(skillRoot, 'delta/architecture-delta.mjs'), 'utf8');
|
||||
assert.match(runtime, /statuses: \['added', 'changed', 'evidence-changed', 'removed', 'moved'\]/);
|
||||
});
|
||||
|
||||
test('mixed semantic and geometry component changes retain both exact forms', () => {
|
||||
const head = read(headFixture);
|
||||
head.components.find((component) => component.id === 'queue').sublabel = 'durable queue v2';
|
||||
const headPath = path.join(tmp, 'mixed-component-head.json');
|
||||
const output = path.join(tmp, 'mixed-component-delta.html');
|
||||
fs.writeFileSync(headPath, JSON.stringify(head));
|
||||
|
||||
const result = run(['compare', 'architecture', baseFixture, headPath, output, '--json']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
const queue = receipt.changes.components.find((change) => change.id === 'queue');
|
||||
assert.equal(queue.status, 'changed');
|
||||
assert.deepEqual(queue.classifications, ['geometry', 'semantic']);
|
||||
const html = fs.readFileSync(output, 'utf8');
|
||||
assert.match(html, /data-change-key="component:queue"[^>]+data-change-target-signature="g:changed:geometry,semantic\|g:moved-from:geometry,semantic"/);
|
||||
assert.deepEqual(validateArchitectureDeltaHtml(html, receipt), { ok: true, checksPassed: 10, checkCount: 10 });
|
||||
});
|
||||
|
||||
test('mixed semantic and geometry relationship changes retain both exact routes', () => {
|
||||
const head = read(headFixture);
|
||||
head.connections.find((connection) => connection.id === 'publish-order').label = 'accepted event';
|
||||
const headPath = path.join(tmp, 'mixed-relationship-head.json');
|
||||
const output = path.join(tmp, 'mixed-relationship-delta.html');
|
||||
fs.writeFileSync(headPath, JSON.stringify(head));
|
||||
|
||||
const result = run(['compare', 'architecture', baseFixture, headPath, output, '--json']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
const publishOrder = receipt.changes.connections.find((change) => change.id === 'publish-order');
|
||||
assert.equal(publishOrder.status, 'changed');
|
||||
assert.deepEqual(publishOrder.classifications, ['geometry', 'semantic']);
|
||||
const html = fs.readFileSync(output, 'utf8');
|
||||
assert.match(html, /data-change-key="relationship:publish-order"[^>]+data-change-target-signature="g:changed:geometry,semantic\|g:moved-from:geometry,semantic\|path:changed:geometry,semantic\|path:moved-from:geometry,semantic\|text:changed:\|text:moved-from:"/);
|
||||
assert.deepEqual(validateArchitectureDeltaHtml(html, receipt), { ok: true, checksPassed: 10, checkCount: 10 });
|
||||
});
|
||||
|
||||
test('baseline boundary title masks stay below current components and carry delta identity', () => {
|
||||
const documentAt = (pos, pad) => ({
|
||||
schema_version: 1,
|
||||
diagram_type: 'architecture',
|
||||
meta: { title: 'Boundary mask z-order', quality_profile: 'standard', viewBox: [600, 400] },
|
||||
components: [{ id: 'node', type: 'backend', label: 'Current node', pos, size: [120, 60] }],
|
||||
connections: [],
|
||||
boundaries: [{ kind: 'region', label: 'Boundary label', wraps: ['node'], pad }],
|
||||
});
|
||||
const basePath = path.join(tmp, 'boundary-mask.base.json');
|
||||
const headPath = path.join(tmp, 'boundary-mask.head.json');
|
||||
const output = path.join(tmp, 'boundary-mask.delta.html');
|
||||
fs.writeFileSync(basePath, JSON.stringify(documentAt([250, 200], 30)));
|
||||
fs.writeFileSync(headPath, JSON.stringify(documentAt([224, 180], 40)));
|
||||
|
||||
const result = run(['compare', 'architecture', basePath, headPath, output, '--json']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const html = fs.readFileSync(output, 'utf8');
|
||||
const delta = html.match(/<section class="canvas" data-view="delta">([\s\S]*?)<\/section>/)?.[1] || '';
|
||||
const currentComponents = delta.indexOf('<!-- Components -->');
|
||||
const currentNode = delta.indexOf('data-node-id="node"', currentComponents);
|
||||
const phantomMask = delta.match(
|
||||
/<rect data-graph-role="structural-frame-label-mask"[^>]*data-delta-state="moved-from"[^>]*data-delta-boundary-state="moved-from"[^>]*data-delta-boundary-mask-key="region:Boundary label"[^>]*\/>/,
|
||||
)?.[0];
|
||||
const currentNodeRect = delta.slice(currentNode).match(/<rect\b[^>]*\/>/)?.[0];
|
||||
assert.ok(phantomMask && currentNodeRect, 'expected the phantom mask and current component rect');
|
||||
const rect = (tag) => Object.fromEntries(
|
||||
[...tag.matchAll(/\b(x|y|width|height)="([^"]+)"/g)].map((match) => [match[1], Number(match[2])]),
|
||||
);
|
||||
const maskBox = rect(phantomMask);
|
||||
const nodeBox = rect(currentNodeRect);
|
||||
const overlaps = maskBox.x < nodeBox.x + nodeBox.width
|
||||
&& maskBox.x + maskBox.width > nodeBox.x
|
||||
&& maskBox.y < nodeBox.y + nodeBox.height
|
||||
&& maskBox.y + maskBox.height > nodeBox.y;
|
||||
assert.equal(overlaps, true, `expected overlap: ${JSON.stringify({ maskBox, nodeBox })}`);
|
||||
assert.ok(delta.indexOf(phantomMask) < currentNode, 'phantom mask must paint below the current component');
|
||||
});
|
||||
|
||||
test('same-label node id changes remain one removal plus one addition', () => {
|
||||
const base = read(baseFixture);
|
||||
const head = read(baseFixture);
|
||||
const cache = head.components.find((component) => component.id === 'cache');
|
||||
cache.id = 'session-store';
|
||||
head.boundaries.forEach((boundary) => {
|
||||
boundary.wraps = boundary.wraps.map((id) => (id === 'cache' ? 'session-store' : id));
|
||||
});
|
||||
head.connections.find((connection) => connection.id === 'session-read').to = 'session-store';
|
||||
|
||||
const receipt = compareArchitecture(base, head);
|
||||
assert.equal(receipt.changes.components.find((change) => change.id === 'cache').status, 'removed');
|
||||
assert.equal(receipt.changes.components.find((change) => change.id === 'session-store').status, 'added');
|
||||
assert.equal(receipt.changes.components.filter((change) => change.headLabel === 'Session Cache' || change.baseLabel === 'Session Cache').length, 2);
|
||||
});
|
||||
|
||||
test('repository mismatch fails and verified matching revisions remain evidence-bounded', () => {
|
||||
const base = read(baseFixture);
|
||||
const head = read(headFixture);
|
||||
base.meta.repository = { url: 'https://github.com/example/one', revision: 'a'.repeat(40) };
|
||||
head.meta.repository = { url: 'https://github.com/example/two', revision: 'b'.repeat(40) };
|
||||
assert.throws(
|
||||
() => compareArchitecture(base, head),
|
||||
(error) => error instanceof ArchitectureDeltaError && error.code === 'delta/repository-mismatch',
|
||||
);
|
||||
|
||||
head.meta.repository.url = 'https://github.com/EXAMPLE/ONE.git/';
|
||||
const receipt = compareArchitecture(base, head, { baseVerified: true, headVerified: true });
|
||||
assert.equal(receipt.proofLevel, 'revision-pinned');
|
||||
assert.equal(receipt.summary.provenanceChanged, true);
|
||||
});
|
||||
|
||||
test('compare CLI writes a deterministic three-state artifact and complete sidecar receipt', () => {
|
||||
const first = path.join(tmp, 'first.html');
|
||||
const second = path.join(tmp, 'second.html');
|
||||
const result = run(['compare', 'architecture', baseFixture, headFixture, first, '--json']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const repeat = run(['compare', 'architecture', baseFixture, headFixture, second, '--json']);
|
||||
assert.equal(repeat.status, 0, repeat.stderr);
|
||||
|
||||
const firstHtml = fs.readFileSync(first, 'utf8');
|
||||
const secondHtml = fs.readFileSync(second, 'utf8');
|
||||
assert.equal(firstHtml, secondHtml);
|
||||
assert.equal((firstHtml.match(/<section class="canvas" data-view=/g) || []).length, 3);
|
||||
assert.match(firstHtml, /data-view="delta">/);
|
||||
assert.match(firstHtml, /data-node-id="cache"[^>]+data-delta-state="removed"/);
|
||||
assert.match(firstHtml, /data-node-id="fraud"[^>]+data-delta-state="added"/);
|
||||
assert.match(firstHtml, /data-node-id="queue"[^>]+data-delta-state="moved-from"/);
|
||||
assert.match(firstHtml, /aria-label="Authored change review"/);
|
||||
assert.equal((firstHtml.match(/class="change-row"/g) || []).length, 11);
|
||||
assert.match(firstHtml, /data-change-key="component:fraud"/);
|
||||
assert.match(firstHtml, /data-change-key="relationship:authorize-payment"/);
|
||||
assert.match(firstHtml, /data-change-key="boundary:region:Production region"/);
|
||||
assert.match(firstHtml, /data-change-target-signature="[^"]+"/);
|
||||
assert.match(firstHtml, /data-delta-boundary-key="region:Production region"/);
|
||||
assert.equal((firstHtml.match(/class="snapshot-frame"/g) || []).length, 2);
|
||||
assert.match(firstHtml, /title="Before architecture explorer"/);
|
||||
assert.match(firstHtml, /title="After architecture explorer"/);
|
||||
assert.match(firstHtml, /id="export-svg"[^>]*>Export SVG</);
|
||||
assert.match(firstHtml, /id="share-card"[^>]*>Share Card</);
|
||||
assert.match(firstHtml, /window\.Archify\.deltaExport = \{ canonicalSvg: canonicalDeltaSvg, shareCard/);
|
||||
assert.match(firstHtml, /canvas\.width = 1200;[\s\S]*canvas\.height = 630;/);
|
||||
assert.match(firstHtml, /structural-frame.*stroke:var\(--delta\)!important/);
|
||||
assert.match(firstHtml, /structural-frame.*data-delta-state="changed".*stroke-dasharray:2 3!important/);
|
||||
assert.match(firstHtml, /data-delta-boundary-state="added".*fill:#34d399!important/);
|
||||
assert.match(firstHtml, /delta-boundary-marker\[data-delta-state\]\{color:var\(--delta\)\}/);
|
||||
assert.match(firstHtml, /No authored architecture changes ·.*movementSummary/);
|
||||
assert.match(firstHtml, /font-family:"JetBrains Mono",ui-monospace/);
|
||||
assert.doesNotMatch(firstHtml, /font-family:Inter|body\{min-width:1080px/);
|
||||
assert.match(firstHtml, /@media\(max-width:760px\)/);
|
||||
assert.match(firstHtml, /\.canvas svg\{min-width:720px;max-height:none\}/);
|
||||
assert.match(firstHtml, /\.changes\{overflow-x:auto\}/);
|
||||
assert.match(firstHtml, /const REVIEW_DWELL_MS = 1400;/);
|
||||
assert.match(firstHtml, /prefers-reduced-motion: reduce/);
|
||||
assert.match(firstHtml, /:not\(\[data-delta-review-current\]\)/);
|
||||
assert.match(firstHtml, /--review-same-opacity:1;--review-change-opacity:1/);
|
||||
assert.match(firstHtml, /--d-focus:#006b8f/);
|
||||
assert.match(firstHtml, /document\.querySelectorAll\('#archify-compare-receipt'\)\.length !== 1/);
|
||||
assert.match(firstHtml, /targetsMatch\(reviewSources\[index\], row, matches\)/);
|
||||
assert.match(firstHtml, /document\.addEventListener\('visibilitychange'/);
|
||||
assert.match(firstHtml, /window\.addEventListener\('beforeprint', overview\)/);
|
||||
assert.match(firstHtml, /aria-current', 'step'/);
|
||||
assert.match(firstHtml, /event\.key === 'Enter' \|\| event\.key === ' '/);
|
||||
const deltaShell = firstHtml.replace(/<iframe\b[^>]*><\/iframe>/g, '');
|
||||
assert.doesNotMatch(deltaShell, /localStorage|sessionStorage|history\.(?:pushState|replaceState)/);
|
||||
assert.doesNotMatch(deltaShell, /setInterval\(/);
|
||||
assert.doesNotMatch(deltaShell, /\b(?:SAFE|LOW RISK|MERGEABLE|NO IMPACT|VERIFIED PR)\b/i);
|
||||
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
const sidecar = read(path.join(tmp, 'first.receipt.json'));
|
||||
assert.deepEqual(sidecar, receipt);
|
||||
assert.equal(receipt.artifact.sha256, JSON.parse(repeat.stdout).artifact.sha256);
|
||||
assert.equal(receipt.validation.checksPassed, receipt.validation.checkCount);
|
||||
assert.equal(receipt.completeness, 'complete');
|
||||
assert.equal(JSON.stringify(receipt).includes(tmp), false);
|
||||
assert.deepEqual(validateArchitectureDeltaHtml(firstHtml, receipt), { ok: true, checksPassed: 10, checkCount: 10 });
|
||||
});
|
||||
|
||||
test('checked-in Checkout compare artifact is reproducible from its authoritative inputs', () => {
|
||||
const artifact = path.join(tmp, 'checked-artifact.html');
|
||||
const receipt = path.join(tmp, 'checked-artifact.receipt.json');
|
||||
const result = run([
|
||||
'compare',
|
||||
'architecture',
|
||||
baseFixture,
|
||||
headFixture,
|
||||
artifact,
|
||||
'--receipt',
|
||||
receipt,
|
||||
'--quality',
|
||||
'showcase',
|
||||
'--json',
|
||||
]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(fs.readFileSync(artifact, 'utf8'), fs.readFileSync(checkedArtifact, 'utf8'));
|
||||
assert.deepEqual(read(receipt), read(checkedReceipt));
|
||||
});
|
||||
|
||||
test('artifact validation fails closed on missing, duplicate, or self-blessed review identity', () => {
|
||||
const output = path.join(tmp, 'review-identity.html');
|
||||
const result = run(['compare', 'architecture', baseFixture, headFixture, output, '--json']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
const html = fs.readFileSync(output, 'utf8');
|
||||
const deltaSection = html.match(/<section class="canvas" data-view="delta">([\s\S]*?)<\/section>/)?.[1];
|
||||
assert.ok(deltaSection);
|
||||
const fraudTag = deltaSection.match(/<g\s+[^>]*\bdata-node-id="fraud"[^>]*>/)?.[0];
|
||||
assert.ok(fraudTag);
|
||||
|
||||
const missing = html.replace(fraudTag, fraudTag.replace('data-node-id="fraud"', 'data-node-id="tampered"'));
|
||||
assert.throws(
|
||||
() => validateArchitectureDeltaHtml(missing, receipt),
|
||||
(error) => error instanceof ArchitectureDeltaError
|
||||
&& error.code === 'delta/artifact-invalid'
|
||||
&& error.details.failures.includes('ambiguous Delta identity component:fraud'),
|
||||
);
|
||||
|
||||
const duplicate = html.replace(fraudTag, `${fraudTag}${fraudTag}`);
|
||||
assert.throws(
|
||||
() => validateArchitectureDeltaHtml(duplicate, receipt),
|
||||
(error) => error instanceof ArchitectureDeltaError
|
||||
&& error.code === 'delta/artifact-invalid'
|
||||
&& error.details.failures.includes('ambiguous Delta identity component:fraud'),
|
||||
);
|
||||
|
||||
const relationshipGroup = deltaSection.match(/<g\s+[^>]*\bdata-edge-id="fraud-check"[^>]*>[\s\S]*?<\/g>/)?.[0];
|
||||
assert.ok(relationshipGroup);
|
||||
const duplicateCompanion = html.replace(relationshipGroup, `${relationshipGroup}${relationshipGroup}`);
|
||||
assert.throws(
|
||||
() => validateArchitectureDeltaHtml(duplicateCompanion, receipt),
|
||||
(error) => error instanceof ArchitectureDeltaError
|
||||
&& error.code === 'delta/artifact-invalid'
|
||||
&& error.details.failures.includes('ambiguous Delta target signature relationship:fraud-check'),
|
||||
);
|
||||
|
||||
const duplicateRowTag = duplicateCompanion.match(/<button class="change-row"[^>]*data-change-key="relationship:fraud-check"[^>]*>/)?.[0];
|
||||
const storedSignature = duplicateRowTag?.match(/data-change-target-signature="([^"]+)"/)?.[1];
|
||||
assert.ok(duplicateRowTag && storedSignature);
|
||||
const selfBlessedSignature = [...storedSignature.split('|'), 'g:added:topology'].sort().join('|');
|
||||
const selfBlessed = duplicateCompanion.replace(
|
||||
duplicateRowTag,
|
||||
duplicateRowTag.replace(`data-change-target-signature="${storedSignature}"`, `data-change-target-signature="${selfBlessedSignature}"`),
|
||||
);
|
||||
assert.throws(
|
||||
() => validateArchitectureDeltaHtml(selfBlessed, receipt),
|
||||
(error) => error instanceof ArchitectureDeltaError
|
||||
&& error.code === 'delta/artifact-invalid'
|
||||
&& error.details.failures.includes('ambiguous Delta target signature relationship:fraud-check'),
|
||||
);
|
||||
|
||||
const missingCompanionState = html.replace(
|
||||
relationshipGroup,
|
||||
relationshipGroup.replace(/\sdata-delta-state="[^"]+"/, ''),
|
||||
);
|
||||
assert.throws(
|
||||
() => validateArchitectureDeltaHtml(missingCompanionState, receipt),
|
||||
(error) => error instanceof ArchitectureDeltaError
|
||||
&& error.code === 'delta/artifact-invalid'
|
||||
&& error.details.failures.includes('missing Delta target state relationship:fraud-check'),
|
||||
);
|
||||
|
||||
const receiptNode = html.match(/<script id="archify-compare-receipt"[\s\S]*?<\/script>/)?.[0];
|
||||
assert.ok(receiptNode);
|
||||
const duplicateReceipt = html.replace(receiptNode, `${receiptNode}${receiptNode}`);
|
||||
assert.throws(
|
||||
() => validateArchitectureDeltaHtml(duplicateReceipt, receipt),
|
||||
(error) => error instanceof ArchitectureDeltaError
|
||||
&& error.code === 'delta/artifact-invalid'
|
||||
&& error.details.failures.includes('expected exactly one embedded compare receipt'),
|
||||
);
|
||||
|
||||
const extraDeltaSvg = html.replace(
|
||||
'<section class="canvas" data-view="delta">',
|
||||
'<section class="canvas" data-view="delta"><svg viewBox="0 0 1 1"></svg>',
|
||||
);
|
||||
assert.throws(
|
||||
() => validateArchitectureDeltaHtml(extraDeltaSvg, receipt),
|
||||
(error) => error instanceof ArchitectureDeltaError
|
||||
&& error.code === 'delta/artifact-invalid'
|
||||
&& error.details.failures.includes('expected exactly one root SVG in the Delta canvas'),
|
||||
);
|
||||
});
|
||||
|
||||
test('formatting-only input changes raw proof but not semantic hash or artifact bytes', () => {
|
||||
const reorderedPath = path.join(tmp, 'reordered-base.json');
|
||||
const reordered = read(baseFixture);
|
||||
reordered.components.reverse();
|
||||
reordered.connections.reverse();
|
||||
reordered.boundaries.forEach((boundary) => boundary.wraps.reverse());
|
||||
fs.writeFileSync(reorderedPath, JSON.stringify(reordered, null, 4));
|
||||
|
||||
const originalOut = path.join(tmp, 'canonical-original.html');
|
||||
const reorderedOut = path.join(tmp, 'canonical-reordered.html');
|
||||
const original = run(['compare', 'architecture', baseFixture, headFixture, originalOut, '--json']);
|
||||
const changed = run(['compare', 'architecture', reorderedPath, headFixture, reorderedOut, '--json']);
|
||||
assert.equal(original.status, 0, original.stderr);
|
||||
assert.equal(changed.status, 0, changed.stderr);
|
||||
const originalReceipt = JSON.parse(original.stdout);
|
||||
const changedReceipt = JSON.parse(changed.stdout);
|
||||
assert.notEqual(originalReceipt.base.rawSha256, changedReceipt.base.rawSha256);
|
||||
assert.equal(originalReceipt.base.semanticSha256, changedReceipt.base.semanticSha256);
|
||||
assert.equal(fs.readFileSync(originalOut, 'utf8'), fs.readFileSync(reorderedOut, 'utf8'));
|
||||
assert.equal(originalReceipt.artifact.sha256, changedReceipt.artifact.sha256);
|
||||
});
|
||||
|
||||
test('compare failure preserves an existing trusted artifact', () => {
|
||||
const invalid = read(headFixture);
|
||||
delete invalid.connections[0].id;
|
||||
const invalidPath = path.join(tmp, 'invalid-head.json');
|
||||
const output = path.join(tmp, 'preserved.html');
|
||||
fs.writeFileSync(invalidPath, JSON.stringify(invalid));
|
||||
fs.writeFileSync(output, 'trusted artifact');
|
||||
|
||||
const result = run(['compare', 'architecture', baseFixture, invalidPath, output, '--json']);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(fs.readFileSync(output, 'utf8'), 'trusted artifact');
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.ok, false);
|
||||
assert.equal(receipt.diagnostics[0].code, 'delta/relationship-id-required');
|
||||
assert.equal(fs.existsSync(path.join(tmp, 'preserved.receipt.json')), false);
|
||||
});
|
||||
|
||||
test('compare validates raw snapshots before canonicalization can discard invalid fields', () => {
|
||||
const invalid = read(baseFixture);
|
||||
invalid.unknown_top_level_fact = true;
|
||||
const invalidPath = path.join(tmp, 'invalid-raw-base.json');
|
||||
const output = path.join(tmp, 'invalid-raw-base.html');
|
||||
fs.writeFileSync(invalidPath, JSON.stringify(invalid));
|
||||
|
||||
const result = run(['compare', 'architecture', invalidPath, headFixture, output, '--json']);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(fs.existsSync(output), false);
|
||||
assert.equal(fs.existsSync(path.join(tmp, 'invalid-raw-base.receipt.json')), false);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.ok, false);
|
||||
assert.equal(receipt.diagnostics[0].code, 'schema/additionalProperties');
|
||||
assert.equal(receipt.diagnostics[0].subject.side, 'base');
|
||||
assert.equal(receipt.diagnostics[0].subject.path, '/');
|
||||
assert.equal(receipt.diagnostics[0].evidence.additionalProperty, 'unknown_top_level_fact');
|
||||
});
|
||||
|
||||
test('compare commit preflights both targets before replacing a trusted pair', () => {
|
||||
const caseRoot = fs.mkdtempSync(path.join(tmp, 'pair-target-'));
|
||||
const output = path.join(caseRoot, 'review.html');
|
||||
const receiptPath = path.join(caseRoot, 'review.receipt.json');
|
||||
fs.writeFileSync(output, 'trusted html');
|
||||
fs.mkdirSync(receiptPath);
|
||||
|
||||
const result = run([
|
||||
'compare', 'architecture', baseFixture, headFixture, output,
|
||||
'--receipt', receiptPath, '--json',
|
||||
]);
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(fs.readFileSync(output, 'utf8'), 'trusted html');
|
||||
assert.equal(fs.statSync(receiptPath).isDirectory(), true);
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.stage, 'commit');
|
||||
assert.equal(failure.diagnostics[0].code, 'delta/commit-target');
|
||||
assert.equal(failure.diagnostics[0].evidence.targetType, 'directory');
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets/template.html'), 'utf8');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-authored-reach-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
function reachabilityFunction() {
|
||||
const start = template.indexOf('function computeReachability(');
|
||||
const end = template.indexOf('\n function reachabilityFor(', start);
|
||||
assert.ok(start >= 0 && end > start, 'template exposes one extractable reachability function');
|
||||
return vm.runInNewContext(`(${template.slice(start, end)})`);
|
||||
}
|
||||
|
||||
test('authored reachability is available in every typed artifact without entering canonical SVG', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /id="focus-reach" hidden/);
|
||||
assert.match(html, /id="btn-reach-upstream"[^>]+aria-pressed="false"/);
|
||||
assert.match(html, /id="btn-reach-downstream"[^>]+aria-pressed="false"/);
|
||||
assert.match(html, /function computeReachability\(originId, direction, relationships\)/);
|
||||
assert.match(html, /svg\.setAttribute\('data-reach-active', direction\)/);
|
||||
assert.doesNotMatch(canonicalSvg(html), /data-reach-(?:active|match|origin|depth)/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('reachability uses stable breadth-first depth, supports cycles, and deduplicates edge fragments', () => {
|
||||
const compute = reachabilityFunction();
|
||||
const relationships = [
|
||||
{ key: 'a-b', from: 'a', to: 'b' },
|
||||
{ key: 'a-c', from: 'a', to: 'c' },
|
||||
{ key: 'b-d', from: 'b', to: 'd' },
|
||||
{ key: 'c-d', from: 'c', to: 'd' },
|
||||
{ key: 'd-b', from: 'd', to: 'b' },
|
||||
{ key: 'x-a', from: 'x', to: 'a' },
|
||||
{ key: 'a-b', from: 'a', to: 'b' },
|
||||
];
|
||||
|
||||
const downstream = compute('a', 'downstream', relationships);
|
||||
assert.deepEqual(Array.from(downstream.nodeIds), ['a', 'b', 'c', 'd']);
|
||||
assert.deepEqual({ ...downstream.depths }, { a: 0, b: 1, c: 1, d: 2 });
|
||||
assert.deepEqual(Array.from(downstream.edgeKeys), ['a-b', 'a-c', 'b-d', 'c-d', 'd-b']);
|
||||
assert.equal(downstream.maxDepth, 2);
|
||||
|
||||
const upstream = compute('d', 'upstream', relationships);
|
||||
assert.deepEqual(Array.from(upstream.nodeIds), ['d', 'b', 'c', 'a', 'x']);
|
||||
assert.deepEqual({ ...upstream.depths }, { d: 0, b: 1, c: 1, a: 2, x: 3 });
|
||||
assert.equal(upstream.maxDepth, 3);
|
||||
assert.equal(compute('a', 'sideways', relationships), null);
|
||||
});
|
||||
|
||||
test('reachability stays explicit, deep-linkable, keyboard reachable, and export-clean', () => {
|
||||
assert.match(template, /Authored Reachability is a bounded graph query over the relationships/);
|
||||
assert.match(template, /direction !== 'upstream' && direction !== 'downstream'/);
|
||||
assert.match(template, /encodeURIComponent\(activeIds\[0\]\) \+ '&reach=' \+ direction/);
|
||||
assert.match(template, /applyReachability\(reach, \{ updateUrl: false, toggle: false, reveal: false \}\)/);
|
||||
assert.match(template, /upstreamBtn\.addEventListener\('click'/);
|
||||
assert.match(template, /downstreamBtn\.addEventListener\('click'/);
|
||||
assert.match(template, /clone\.removeAttribute\('data-reach-active'\)/);
|
||||
assert.match(template, /clone\.querySelectorAll\('\[data-reach-match\], \[data-reach-origin\], \[data-reach-depth\]'/);
|
||||
assert.match(template, /!clone\.hasAttribute\('data-reach-active'\)/);
|
||||
assert.match(template, /svg\[data-preset="blueprint"\]\[data-reach-active\]/);
|
||||
assert.match(template, /\.diagram-container svg\[data-reach-active\] \[data-node-id\]/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const skill = fs.readFileSync(path.join(skillRoot, 'SKILL.md'), 'utf8');
|
||||
const authoringContract = fs.readFileSync(
|
||||
path.join(skillRoot, 'references', 'authoring-contract.md'),
|
||||
'utf8',
|
||||
);
|
||||
const schemaReadme = fs.readFileSync(path.join(skillRoot, 'schemas', 'README.md'), 'utf8');
|
||||
|
||||
test('semantic relationship labels are preserved and deletion is not a geometry repair', () => {
|
||||
for (const [name, source] of [['SKILL.md', skill], ['authoring contract', authoringContract]]) {
|
||||
assert.match(source, /Relationship labels are semantic data/i, name);
|
||||
assert.match(source, /move the label[\s\S]*adjust the route or spacing[\s\S]*shorten/i, name);
|
||||
assert.match(source, /protocol[\s\S]*action[\s\S]*direction[\s\S]*synchronous[\s\S]*asynchronous[\s\S]*cross-boundary mechanism/i, name);
|
||||
assert.match(source, /Omit only wording[\s\S]*fully implied by both endpoints/i, name);
|
||||
assert.match(source, /Preserve every meaningful label/i, name);
|
||||
assert.match(source, /deleting it is not\s+a (?:geometry|spacing) repair/i, name);
|
||||
}
|
||||
});
|
||||
|
||||
test('schema policy documents the workflow v1/v2 compatibility boundary', () => {
|
||||
assert.match(schemaReadme, /Workflow[^\n]*schema versions? 1 and 2/i);
|
||||
assert.match(schemaReadme, /other four[^\n]*schema_version[^\n]*1/i);
|
||||
assert.doesNotMatch(schemaReadme, /schema_version` is `"const": 1`/);
|
||||
});
|
||||
|
||||
test('deployment ownership stays explicit, fact-backed, and cannot be removed to pass', () => {
|
||||
assert.match(skill, /Omit `meta\.engineering_profile` by default/);
|
||||
assert.match(skill, /Region.*cluster.*security boundar.*do not.*enable/i);
|
||||
assert.match(skill, /production deployment topology.*ownership.*fail-closed deployment review/i);
|
||||
assert.match(skill, /must not remove.*engineering profile.*pass validation/i);
|
||||
});
|
||||
|
||||
test('visual-check stays a pending sidecar receipt instead of a polish claim', () => {
|
||||
const deliveryContract = fs.readFileSync(
|
||||
path.join(skillRoot, 'references', 'delivery-contract.md'),
|
||||
'utf8',
|
||||
);
|
||||
for (const [name, source] of [['SKILL.md', skill], ['delivery contract', deliveryContract]]) {
|
||||
assert.match(source, /visual-check <output\.html> --json/, name);
|
||||
assert.match(source, /1440×900[\s\S]*1600×1000[\s\S]*1920×1080[\s\S]*2048×1320/, name);
|
||||
assert.match(source, /visualReview: "pending"/, name);
|
||||
assert.match(source, /never changes.*delivered|without (?:rerendering or )?modifying/i, name);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function render(mode, doc) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-port-spread-'));
|
||||
const input = path.join(tmp, 'input.json');
|
||||
const output = path.join(tmp, 'output.html');
|
||||
fs.writeFileSync(input, JSON.stringify(doc));
|
||||
try {
|
||||
execFileSync('node', [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
input,
|
||||
output,
|
||||
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function connectionPoints(html, id) {
|
||||
const pattern = new RegExp(`data-edge-id="${id}"[^>]+data-composition-points="([^"]+)"`);
|
||||
const match = html.match(pattern);
|
||||
assert.ok(match, `missing rendered connection ${id}`);
|
||||
return match[1].split(';').map((point) => point.split(',').map(Number));
|
||||
}
|
||||
|
||||
function fanOutArchitecture(connections) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
diagram_type: 'architecture',
|
||||
meta: { title: 'Automatic port spread' },
|
||||
components: [
|
||||
{ id: 'hub', type: 'backend', label: 'Hub', pos: [100, 280], size: [120, 60] },
|
||||
{ id: 'upper', type: 'external', label: 'Upper', pos: [500, 100], size: [120, 60] },
|
||||
{ id: 'middle', type: 'database', label: 'Middle', pos: [500, 280], size: [120, 60] },
|
||||
{ id: 'lower', type: 'cloud', label: 'Lower', pos: [500, 460], size: [120, 60] },
|
||||
],
|
||||
connections,
|
||||
};
|
||||
}
|
||||
|
||||
test('architecture: automatic fan-out uses distinct symmetric ports with corner clearance', () => {
|
||||
const html = render('architecture', fanOutArchitecture([
|
||||
{ id: 'to-upper', from: 'hub', to: 'upper' },
|
||||
{ id: 'to-middle', from: 'hub', to: 'middle' },
|
||||
{ id: 'to-lower', from: 'hub', to: 'lower' },
|
||||
]));
|
||||
|
||||
assert.deepEqual(connectionPoints(html, 'to-upper')[0], [220, 296]);
|
||||
assert.deepEqual(connectionPoints(html, 'to-middle')[0], [220, 310]);
|
||||
assert.deepEqual(connectionPoints(html, 'to-lower')[0], [220, 324]);
|
||||
});
|
||||
|
||||
test('architecture: automatic port assignment is stable when relationship input order changes', () => {
|
||||
const connections = [
|
||||
{ id: 'to-upper', from: 'hub', to: 'upper' },
|
||||
{ id: 'to-middle', from: 'hub', to: 'middle' },
|
||||
{ id: 'to-lower', from: 'hub', to: 'lower' },
|
||||
];
|
||||
const forward = render('architecture', fanOutArchitecture(connections));
|
||||
const reversed = render('architecture', fanOutArchitecture([...connections].reverse()));
|
||||
|
||||
for (const connection of connections) {
|
||||
assert.deepEqual(
|
||||
connectionPoints(forward, connection.id),
|
||||
connectionPoints(reversed, connection.id),
|
||||
`${connection.id} moved after input reordering`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('architecture: a singly spread near-aligned vertical relationship keeps one direct axis', () => {
|
||||
const html = render('architecture', {
|
||||
schema_version: 1,
|
||||
diagram_type: 'architecture',
|
||||
meta: { title: 'Near-aligned fan-out' },
|
||||
components: [
|
||||
{ id: 'api', type: 'backend', label: 'API', pos: [675, 300], size: [120, 60] },
|
||||
{ id: 'auth', type: 'security', label: 'Auth', pos: [570, 120], size: [100, 60] },
|
||||
{ id: 'cache', type: 'database', label: 'Cache', pos: [685, 120], size: [100, 60] },
|
||||
],
|
||||
connections: [
|
||||
{ id: 'verify', from: 'api', to: 'auth', fromSide: 'top', toSide: 'bottom' },
|
||||
{ id: 'read', from: 'api', to: 'cache', fromSide: 'top', toSide: 'bottom' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(connectionPoints(html, 'read'), [[742, 300], [742, 180]]);
|
||||
assert.notDeepEqual(connectionPoints(html, 'verify')[0], connectionPoints(html, 'read')[0]);
|
||||
});
|
||||
|
||||
test('architecture: a singly spread near-aligned horizontal relationship keeps one direct axis', () => {
|
||||
const html = render('architecture', {
|
||||
schema_version: 1,
|
||||
diagram_type: 'architecture',
|
||||
meta: { title: 'Near-aligned horizontal fan-out' },
|
||||
components: [
|
||||
{ id: 'hub', type: 'backend', label: 'Hub', pos: [100, 100], size: [120, 60] },
|
||||
{ id: 'direct', type: 'database', label: 'Direct', pos: [500, 107], size: [120, 60] },
|
||||
{ id: 'branch', type: 'cloud', label: 'Branch', pos: [500, 300], size: [120, 60] },
|
||||
],
|
||||
connections: [
|
||||
{ id: 'hub-direct', from: 'hub', to: 'direct', fromSide: 'right', toSide: 'left' },
|
||||
{ id: 'hub-branch', from: 'hub', to: 'branch', fromSide: 'right', toSide: 'left' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(connectionPoints(html, 'hub-direct'), [[220, 123], [500, 123]]);
|
||||
assert.notDeepEqual(connectionPoints(html, 'hub-branch')[0], connectionPoints(html, 'hub-direct')[0]);
|
||||
});
|
||||
|
||||
test('architecture: a shared bottom port keeps its aligned child relationship straight', () => {
|
||||
const doc = {
|
||||
schema_version: 1,
|
||||
diagram_type: 'architecture',
|
||||
meta: { title: 'Vertical child relationship' },
|
||||
components: [
|
||||
{ id: 'parent', type: 'backend', label: 'Parent Session', pos: [300, 100], size: [200, 60] },
|
||||
{ id: 'terminal', type: 'backend', label: 'Background terminal', pos: [300, 300], size: [200, 60] },
|
||||
{ id: 'workflow', type: 'backend', label: 'Workflow', pos: [560, 300], size: [180, 60] },
|
||||
],
|
||||
connections: [
|
||||
{ id: 'parent-terminal', from: 'parent', to: 'terminal', fromSide: 'bottom', toSide: 'top' },
|
||||
{ id: 'parent-workflow', from: 'parent', to: 'workflow', fromSide: 'bottom', toSide: 'top' },
|
||||
],
|
||||
};
|
||||
const forward = render('architecture', doc);
|
||||
const reversed = render('architecture', {
|
||||
...doc,
|
||||
connections: [...doc.connections].reverse(),
|
||||
});
|
||||
|
||||
assert.deepEqual(connectionPoints(forward, 'parent-terminal'), [[393, 160], [393, 300]]);
|
||||
assert.notDeepEqual(
|
||||
connectionPoints(forward, 'parent-workflow')[0],
|
||||
connectionPoints(forward, 'parent-terminal')[0],
|
||||
);
|
||||
for (const connection of doc.connections) {
|
||||
assert.deepEqual(
|
||||
connectionPoints(forward, connection.id),
|
||||
connectionPoints(reversed, connection.id),
|
||||
`${connection.id} moved after input reordering`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('architecture: incoming and outgoing relationships keep distinct bottom ports while the direct child stays straight', () => {
|
||||
const html = render('architecture', {
|
||||
schema_version: 1,
|
||||
diagram_type: 'architecture',
|
||||
meta: { title: 'Shared incoming and outgoing side' },
|
||||
components: [
|
||||
{ id: 'workflow', type: 'backend', label: 'Workflow', pos: [80, 320], size: [180, 60] },
|
||||
{ id: 'child', type: 'security', label: 'Child boundary', pos: [300, 100], size: [200, 60] },
|
||||
{ id: 'footer', type: 'frontend', label: 'Footer', pos: [300, 320], size: [200, 60] },
|
||||
],
|
||||
connections: [
|
||||
{ id: 'workflow-child', from: 'workflow', to: 'child', fromSide: 'right', toSide: 'bottom' },
|
||||
{ id: 'child-footer', from: 'child', to: 'footer', fromSide: 'bottom', toSide: 'top' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(connectionPoints(html, 'child-footer'), [[407, 160], [407, 320]]);
|
||||
assert.notDeepEqual(
|
||||
connectionPoints(html, 'workflow-child').at(-1),
|
||||
connectionPoints(html, 'child-footer')[0],
|
||||
);
|
||||
});
|
||||
|
||||
test('architecture: a near-aligned relationship keeps the outside bridge when both endpoints are spread', () => {
|
||||
const html = render('architecture', {
|
||||
schema_version: 1,
|
||||
diagram_type: 'architecture',
|
||||
meta: { title: 'Two-sided port competition' },
|
||||
components: [
|
||||
{ id: 'source', type: 'backend', label: 'Source', pos: [300, 320], size: [160, 60] },
|
||||
{ id: 'source-peer', type: 'backend', label: 'Source peer', pos: [80, 320], size: [160, 60] },
|
||||
{ id: 'target', type: 'database', label: 'Target', pos: [300, 100], size: [160, 60] },
|
||||
{ id: 'target-peer', type: 'database', label: 'Target peer', pos: [560, 100], size: [160, 60] },
|
||||
],
|
||||
connections: [
|
||||
{ id: 'source-target', from: 'source', to: 'target', fromSide: 'top', toSide: 'bottom' },
|
||||
{ id: 'source-peer-target', from: 'source-peer', to: 'target', fromSide: 'top', toSide: 'bottom' },
|
||||
{ id: 'source-target-peer', from: 'source', to: 'target-peer', fromSide: 'top', toSide: 'bottom' },
|
||||
],
|
||||
});
|
||||
|
||||
const points = connectionPoints(html, 'source-target');
|
||||
assert.ok(points.length > 2);
|
||||
assert.notEqual(points[0][0], points.at(-1)[0]);
|
||||
});
|
||||
|
||||
test('architecture: a singly spread near-aligned relationship keeps the bridge when its direct axis is blocked', () => {
|
||||
const html = render('architecture', {
|
||||
schema_version: 1,
|
||||
diagram_type: 'architecture',
|
||||
meta: { title: 'Blocked vertical axis' },
|
||||
components: [
|
||||
{ id: 'parent', type: 'backend', label: 'Parent', pos: [300, 100], size: [200, 60] },
|
||||
{ id: 'terminal', type: 'backend', label: 'Terminal', pos: [300, 400], size: [200, 60] },
|
||||
{ id: 'workflow', type: 'backend', label: 'Workflow', pos: [560, 400], size: [180, 60] },
|
||||
{ id: 'obstacle', type: 'external', label: 'X', pos: [382, 245], size: [26, 60] },
|
||||
],
|
||||
connections: [
|
||||
{ id: 'parent-terminal', from: 'parent', to: 'terminal', fromSide: 'bottom', toSide: 'top' },
|
||||
{ id: 'parent-workflow', from: 'parent', to: 'workflow', fromSide: 'bottom', toSide: 'top' },
|
||||
],
|
||||
});
|
||||
|
||||
const points = connectionPoints(html, 'parent-terminal');
|
||||
assert.ok(points.length > 2);
|
||||
assert.notEqual(points[0][0], points.at(-1)[0]);
|
||||
});
|
||||
|
||||
test('architecture: single and explicitly positioned relationships keep legacy anchors', () => {
|
||||
const doc = fanOutArchitecture([
|
||||
{ id: 'single', from: 'hub', to: 'middle' },
|
||||
{ id: 'via', from: 'hub', to: 'upper', via: [[300, 310], [300, 130]] },
|
||||
{ id: 'fixed-route', from: 'hub', to: 'lower', route: 'orthogonal-h' },
|
||||
{ id: 'fixed-label', from: 'hub', to: 'upper', label: 'contract', labelAt: [360, 200] },
|
||||
]);
|
||||
const html = render('architecture', doc);
|
||||
|
||||
assert.deepEqual(connectionPoints(html, 'single'), [[220, 310], [500, 310]]);
|
||||
assert.deepEqual(connectionPoints(html, 'via'), [[220, 310], [300, 310], [300, 130], [500, 130]]);
|
||||
assert.deepEqual(connectionPoints(html, 'fixed-route'), [[220, 310], [360, 310], [360, 490], [500, 490]]);
|
||||
assert.deepEqual(connectionPoints(html, 'fixed-label'), [[220, 310], [360, 310], [360, 130], [500, 130]]);
|
||||
});
|
||||
|
||||
test('architecture: an unspread near-aligned connection shares one horizontal axis', () => {
|
||||
const html = render('architecture', {
|
||||
schema_version: 1,
|
||||
diagram_type: 'architecture',
|
||||
meta: { title: 'Near-aligned single connection' },
|
||||
components: [
|
||||
{ id: 'console', type: 'frontend', label: 'Console', pos: [260, 300], size: [170, 64] },
|
||||
{ id: 'controlplane', type: 'backend', label: 'Control plane', pos: [500, 300], size: [190, 72] },
|
||||
],
|
||||
connections: [
|
||||
{ id: 'console-controlplane', from: 'console', to: 'controlplane', label: 'REST /api', variant: 'emphasis', labelDy: -36 },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(connectionPoints(html, 'console-controlplane'), [[430, 332], [500, 332]]);
|
||||
});
|
||||
|
||||
test('workflow: automatic cross-lane fan-out selects distinct perpendicular source sides', () => {
|
||||
const html = render('workflow', {
|
||||
schema_version: 1,
|
||||
diagram_type: 'workflow',
|
||||
meta: { title: 'Workflow port spread' },
|
||||
lanes: [
|
||||
{ id: 'upper-lane', label: 'Upper' },
|
||||
{ id: 'hub-lane', label: 'Hub' },
|
||||
{ id: 'lower-lane', label: 'Lower' },
|
||||
],
|
||||
nodes: [
|
||||
{ id: 'hub', lane: 'hub-lane', col: 0, type: 'backend', label: 'Hub' },
|
||||
{ id: 'upper', lane: 'upper-lane', col: 3, type: 'external', label: 'Upper' },
|
||||
{ id: 'middle', lane: 'hub-lane', col: 3, type: 'database', label: 'Middle' },
|
||||
{ id: 'lower', lane: 'lower-lane', col: 3, type: 'cloud', label: 'Lower' },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'to-upper', from: 'hub', to: 'upper' },
|
||||
{ id: 'to-middle', from: 'hub', to: 'middle' },
|
||||
{ id: 'to-lower', from: 'hub', to: 'lower' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(connectionPoints(html, 'to-upper')[0], [88, 217]);
|
||||
assert.deepEqual(connectionPoints(html, 'to-middle')[0], [134, 243]);
|
||||
assert.deepEqual(connectionPoints(html, 'to-lower')[0], [88, 269]);
|
||||
});
|
||||
|
||||
test('dataflow: automatic fan-out spreads flows without changing their authored topology', () => {
|
||||
const html = render('dataflow', {
|
||||
schema_version: 1,
|
||||
diagram_type: 'dataflow',
|
||||
meta: { title: 'Data-flow port spread' },
|
||||
stages: [{ label: 'Source' }, { label: 'Transform' }, { label: 'Sinks' }],
|
||||
nodes: [
|
||||
{ id: 'hub', type: 'backend', label: 'Hub', stage: 0, row: 2 },
|
||||
{ id: 'upper', type: 'external', label: 'Upper', stage: 2, row: 0 },
|
||||
{ id: 'middle', type: 'database', label: 'Middle', stage: 2, row: 2 },
|
||||
{ id: 'lower', type: 'cloud', label: 'Lower', stage: 2, row: 4 },
|
||||
],
|
||||
flows: [
|
||||
{ id: 'to-upper', from: 'hub', to: 'upper', label: 'upper feed' },
|
||||
{ id: 'to-middle', from: 'hub', to: 'middle', label: 'middle feed' },
|
||||
{ id: 'to-lower', from: 'hub', to: 'lower', label: 'lower feed' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(connectionPoints(html, 'to-upper')[0], [156, 372]);
|
||||
assert.deepEqual(connectionPoints(html, 'to-middle')[0], [156, 385]);
|
||||
assert.deepEqual(connectionPoints(html, 'to-lower')[0], [156, 398]);
|
||||
});
|
||||
|
||||
test('lifecycle: automatic fan-out spreads transitions across lifecycle bands', () => {
|
||||
const html = render('lifecycle', {
|
||||
schema_version: 1,
|
||||
diagram_type: 'lifecycle',
|
||||
meta: { title: 'Lifecycle port spread' },
|
||||
lanes: [
|
||||
{ id: 'main', label: 'Main' },
|
||||
{ id: 'event', label: 'Events' },
|
||||
{ id: 'terminal', label: 'Outcomes' },
|
||||
],
|
||||
states: [
|
||||
{ id: 'hub', type: 'active', label: 'Hub', lane: 'event', col: 0 },
|
||||
{ id: 'upper', type: 'waiting', label: 'Upper', lane: 'main', col: 4 },
|
||||
{ id: 'middle', type: 'success', label: 'Middle', lane: 'event', col: 2 },
|
||||
{ id: 'lower', type: 'failure', label: 'Lower', lane: 'terminal', col: 2 },
|
||||
],
|
||||
transitions: [
|
||||
{ id: 'to-upper', from: 'hub', to: 'upper' },
|
||||
{ id: 'to-middle', from: 'hub', to: 'middle' },
|
||||
{ id: 'to-lower', from: 'hub', to: 'lower' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(connectionPoints(html, 'to-upper')[0], [465, 294]);
|
||||
assert.deepEqual(connectionPoints(html, 'to-middle')[0], [465, 307]);
|
||||
assert.deepEqual(connectionPoints(html, 'to-lower')[0], [465, 320]);
|
||||
});
|
||||
|
||||
test('lifecycle: same-band port spread remains orthogonal', () => {
|
||||
const html = render('lifecycle', {
|
||||
schema_version: 1,
|
||||
diagram_type: 'lifecycle',
|
||||
meta: { title: 'Orthogonal same-band spread' },
|
||||
lanes: [{ id: 'main', label: 'Main' }],
|
||||
states: [
|
||||
{ id: 'hub', type: 'active', label: 'Hub', lane: 'main', col: 0 },
|
||||
{ id: 'upper', type: 'waiting', label: 'Upper', lane: 'main', col: 2, yOffset: -50 },
|
||||
{ id: 'lower', type: 'success', label: 'Lower', lane: 'main', col: 4, yOffset: 50 },
|
||||
],
|
||||
transitions: [
|
||||
{ id: 'to-upper', from: 'hub', to: 'upper' },
|
||||
{ id: 'to-lower', from: 'hub', to: 'lower' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(connectionPoints(html, 'to-upper'), [
|
||||
[153, 150], [248, 150], [248, 107], [343, 107],
|
||||
]);
|
||||
assert.deepEqual(connectionPoints(html, 'to-lower'), [
|
||||
[153, 164], [402, 164], [402, 207], [651, 207],
|
||||
]);
|
||||
});
|
||||
|
||||
test('skill and READMEs describe automatic port spread as bounded default behavior', () => {
|
||||
const skill = fs.readFileSync(path.join(skillRoot, 'SKILL.md'), 'utf8');
|
||||
assert.match(skill, /Automatic Port Spread is a default renderer behavior/);
|
||||
assert.match(skill, /single relationship|single relationships/);
|
||||
assert.match(skill, /explicit `via`.*`channelX`.*`channelY`.*`labelAt`/);
|
||||
assert.match(skill, /facing automatic ports \(`left`\/`right` or `top`\/`bottom`\).*one shared axis/);
|
||||
|
||||
const authoringContract = fs.readFileSync(path.join(skillRoot, 'references/authoring-contract.md'), 'utf8');
|
||||
assert.match(authoringContract, /unobstructed facing ports.*may share one horizontal or vertical axis/);
|
||||
|
||||
const repoRoot = path.resolve(skillRoot, '..');
|
||||
for (const file of ['README.md', 'README_EN.md']) {
|
||||
assert.match(fs.readFileSync(path.join(repoRoot, file), 'utf8'), /shared automatic endpoints spread deterministically/);
|
||||
}
|
||||
assert.match(fs.readFileSync(path.join(repoRoot, 'README_ZH.md'), 'utf8'), /共享的自动端点会确定性展开/);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(here, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-base-input-compatibility-'));
|
||||
|
||||
function renderBaseFixture(type, name) {
|
||||
const input = path.join(skillRoot, 'test', 'fixtures', 'v1-baseline', name);
|
||||
const output = path.join(tmp, `${name}.html`);
|
||||
return spawnSync(process.execPath, [
|
||||
path.join(skillRoot, 'bin', 'archify.mjs'),
|
||||
'render',
|
||||
type,
|
||||
input,
|
||||
output,
|
||||
], {
|
||||
cwd: skillRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
test('base named-route fixtures remain valid without redundant authored endpoint sides', () => {
|
||||
for (const [type, name] of [
|
||||
['dataflow', 'event-stream.dataflow.json'],
|
||||
['architecture', 'production-deployment.architecture.json'],
|
||||
]) {
|
||||
const result = renderBaseFixture(type, name);
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`${type} base input must remain valid:\n${result.stdout || result.stderr}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,654 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { BRAND_MARKS } from '../renderers/shared/generated-brand-marks.mjs';
|
||||
import { isPrivateBrandAddress, prepareDiagramBrandMarks } from '../renderers/shared/brand-marks.mjs';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(here, '..');
|
||||
const cli = path.join(skillRoot, 'bin', 'archify.mjs');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-brand-marks-'));
|
||||
const cases = {
|
||||
architecture: ['web-app.architecture.json', 'components'],
|
||||
workflow: ['agent-tool-call.workflow.json', 'nodes'],
|
||||
sequence: ['cache-miss-request.sequence.json', 'participants'],
|
||||
dataflow: ['product-analytics.dataflow.json', 'nodes'],
|
||||
lifecycle: ['agent-run.lifecycle.json', 'states'],
|
||||
};
|
||||
|
||||
function writeFixture(type, name, brand, customize) {
|
||||
const [example, collection] = cases[type];
|
||||
const value = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', example), 'utf8'));
|
||||
value[collection][0].brand = brand;
|
||||
customize?.(value, value[collection][0]);
|
||||
const file = path.join(tmp, `${name}.${type}.json`);
|
||||
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
||||
return file;
|
||||
}
|
||||
|
||||
function renderSync(type, input, name, env = {}) {
|
||||
const output = path.join(tmp, `${name}.html`);
|
||||
const result = spawnSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${type}/render-${type}.mjs`),
|
||||
input,
|
||||
output,
|
||||
], {
|
||||
cwd: skillRoot,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
return { result, output, html: fs.existsSync(output) ? fs.readFileSync(output, 'utf8') : '' };
|
||||
}
|
||||
|
||||
function renderAsync(type, input, name, env = {}) {
|
||||
const output = path.join(tmp, `${name}.html`);
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${type}/render-${type}.mjs`),
|
||||
input,
|
||||
output,
|
||||
], {
|
||||
cwd: skillRoot,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.on('close', (status) => resolve({
|
||||
status,
|
||||
stdout,
|
||||
stderr,
|
||||
output,
|
||||
html: fs.existsSync(output) ? fs.readFileSync(output, 'utf8') : '',
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function runCliAsync(args, env = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [cli, ...args], {
|
||||
cwd: skillRoot,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.on('close', (status) => resolve({ status, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
function nodeBlock(html, id) {
|
||||
const startToken = `<g id="node-${id}"`;
|
||||
const start = html.indexOf(startToken);
|
||||
if (start === -1) return '';
|
||||
const candidates = [
|
||||
html.indexOf('\n <g id="node-', start + startToken.length),
|
||||
html.indexOf('\n <!-- Connection labels', start + startToken.length),
|
||||
html.indexOf('\n <!-- Transition labels', start + startToken.length),
|
||||
html.indexOf('\n <!-- Message labels', start + startToken.length),
|
||||
].filter((value) => value !== -1);
|
||||
return html.slice(start, candidates.length ? Math.min(...candidates) : html.length);
|
||||
}
|
||||
|
||||
test('generated catalog exposes a substantial, unique, provenance-backed preset library', () => {
|
||||
assert.equal(BRAND_MARKS.length, 107);
|
||||
assert.equal(new Set(BRAND_MARKS.map((mark) => mark.id)).size, BRAND_MARKS.length);
|
||||
for (const mark of BRAND_MARKS) {
|
||||
assert.match(mark.id, /^[a-z0-9]+(?:-[a-z0-9]+)*$/);
|
||||
assert.ok(mark.title);
|
||||
assert.ok(mark.category);
|
||||
assert.match(mark.hex, /^[0-9A-F]{6}$/i);
|
||||
assert.match(mark.path, /^[Mm]/);
|
||||
assert.ok(mark.provenance?.source);
|
||||
}
|
||||
});
|
||||
|
||||
test('brand discovery resolves model names, aliases, domains, and Chinese channel aliases', () => {
|
||||
for (const [query, expected] of [
|
||||
['GPT', 'openai'],
|
||||
['Gemini', 'google-gemini'],
|
||||
['github.com', 'github'],
|
||||
['微信', 'wechat'],
|
||||
]) {
|
||||
const result = spawnSync(process.execPath, [cli, 'brands', query, '--json'], {
|
||||
cwd: skillRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.ok, true);
|
||||
assert.ok(receipt.marks.some((mark) => mark.id === expected), query);
|
||||
}
|
||||
});
|
||||
|
||||
test('all five renderers keep the semantic sigil and add one export-safe brand badge', () => {
|
||||
for (const type of Object.keys(cases)) {
|
||||
const input = writeFixture(type, `preset-${type}`, 'openai', (_diagram, node) => {
|
||||
if (type === 'lifecycle') node.step = node.step || '01';
|
||||
});
|
||||
const { result, html } = renderSync(type, input, `preset-${type}`);
|
||||
assert.equal(result.status, 0, `${type}: ${result.stderr || result.stdout}`);
|
||||
assert.match(html, /data-node-brand="OpenAI"/i, type);
|
||||
assert.match(html, /data-brand-mark="openai"[^>]+data-brand-status="preset"/i, type);
|
||||
assert.match(html, /class="semantic-sigil /, type);
|
||||
assert.match(html, /<title>[^<]*OpenAI<\/title>/i, type);
|
||||
|
||||
const [, collection] = cases[type];
|
||||
const diagram = JSON.parse(fs.readFileSync(input, 'utf8'));
|
||||
const block = nodeBlock(html, diagram[collection][0].id);
|
||||
const frame = block.match(/<rect x="([-\d.]+)" y="([-\d.]+)" width="([-\d.]+)" height="([-\d.]+)" rx="[^"]+" class="c-mask"\/>/);
|
||||
const semantic = block.match(/data-semantic-sigil[^>]+translate\(([-\d.]+) ([-\d.]+)\)/);
|
||||
const brand = block.match(/data-brand-mark="openai"[^>]+translate\(([-\d.]+) ([-\d.]+)\)">\s*<rect width="([-\d.]+)" height="([-\d.]+)" rx="([-\d.]+)" class="brand-mark-badge"\/>/);
|
||||
assert.ok(frame && semantic && brand, `${type}: expected node frame, semantic sigil, and brand badge`);
|
||||
|
||||
const [frameX, frameY, frameWidth] = frame.slice(1, 4).map(Number);
|
||||
const [, semanticY] = semantic.slice(1, 3).map(Number);
|
||||
const [brandX, brandY, brandWidth, brandHeight, brandRadius] = brand.slice(1, 6).map(Number);
|
||||
assert.equal(brandWidth, 16, `${type}: brand badge width`);
|
||||
assert.equal(brandHeight, 16, `${type}: brand badge height`);
|
||||
assert.equal(brandRadius, 4, `${type}: brand badge radius`);
|
||||
assert.equal(brandY - frameY, 6, `${type}: brand badge top inset`);
|
||||
assert.equal(frameX + frameWidth - (brandX + brandWidth), 6, `${type}: brand badge right inset`);
|
||||
assert.equal(brandY, semanticY, `${type}: brand and semantic marks share a top rail`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a branded node fails before its semantic sigil, label, and brand badge can overlap', () => {
|
||||
const input = writeFixture('workflow', 'narrow-brand-rail', 'openai', (_diagram, node) => {
|
||||
node.label = 'A';
|
||||
delete node.sublabel;
|
||||
node.width = 32;
|
||||
});
|
||||
const { result, html } = renderSync('workflow', input, 'narrow-brand-rail');
|
||||
|
||||
assert.equal(result.status, 1, result.stderr || result.stdout);
|
||||
assert.match(result.stderr, /brand top rail/i);
|
||||
assert.equal(html, '');
|
||||
});
|
||||
|
||||
test('every renderer enforces the same collision-free brand top rail', () => {
|
||||
for (const type of ['architecture', 'sequence', 'dataflow', 'lifecycle']) {
|
||||
const input = writeFixture(type, `narrow-brand-rail-${type}`, 'openai', (_diagram, node) => {
|
||||
node.label = type === 'sequence' ? 'ABCDEFGHI' : 'A';
|
||||
delete node.sublabel;
|
||||
delete node.tag;
|
||||
if (type === 'architecture') node.size = [32, 60];
|
||||
if (type === 'dataflow' || type === 'lifecycle') node.width = 48;
|
||||
});
|
||||
const { result, html } = renderSync(type, input, `narrow-brand-rail-${type}`);
|
||||
assert.equal(result.status, 1, `${type}: ${result.stderr || result.stdout}`);
|
||||
assert.match(result.stderr, /brand top rail/i, type);
|
||||
assert.equal(html, '', type);
|
||||
}
|
||||
});
|
||||
|
||||
test('branded lifecycle states move the semantic stamp left and keep the brand at upper right', () => {
|
||||
const input = writeFixture('lifecycle', 'lifecycle-placement', 'openai', (_diagram, node) => {
|
||||
node.step = '01';
|
||||
});
|
||||
const { result, html } = renderSync('lifecycle', input, 'lifecycle-placement');
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const id = JSON.parse(fs.readFileSync(input, 'utf8')).states[0].id;
|
||||
const block = nodeBlock(html, id);
|
||||
const semanticX = Number(block.match(/data-semantic-sigil[^>]+translate\(([-\d.]+)/)?.[1]);
|
||||
const brandX = Number(block.match(/data-brand-mark[^>]+translate\(([-\d.]+)/)?.[1]);
|
||||
assert.ok(Number.isFinite(semanticX) && Number.isFinite(brandX) && semanticX < brandX, block);
|
||||
assert.match(block, /data-detail="fine"[^>]+>01<\/text>/);
|
||||
});
|
||||
|
||||
test('known-brand URLs use the bundled vector instead of the network', () => {
|
||||
const input = writeFixture('architecture', 'known-domain', 'https://github.com/tt-a1i/archify');
|
||||
const { result, html } = renderSync('architecture', input, 'known-domain');
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.match(html, /data-brand-mark="github"[^>]+data-brand-status="preset"/);
|
||||
assert.doesNotMatch(html, /data-brand-status="captured"/);
|
||||
});
|
||||
|
||||
test('unknown URL strings fail closed until an exact captured digest is authored', () => {
|
||||
const input = writeFixture('architecture', 'unpinned-link', 'https://brand.example.invalid/');
|
||||
const result = spawnSync(process.execPath, [cli, 'validate', 'architecture', input, '--json'], {
|
||||
cwd: skillRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 1, result.stderr || result.stdout);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.ok, false);
|
||||
assert.ok(receipt.diagnostics.some((entry) => entry.code === 'brand/unpinned-url'));
|
||||
assert.ok(receipt.diagnostics.some((entry) => entry.supportedFixes.some((fix) => fix.includes('brands capture'))));
|
||||
});
|
||||
|
||||
test('capture command returns a digest-pinned brand object that renders reproducibly', async () => {
|
||||
let pageHits = 0;
|
||||
let iconHits = 0;
|
||||
const icon = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.url === '/mark.png') {
|
||||
iconHits += 1;
|
||||
response.writeHead(200, { 'content-type': 'image/png' });
|
||||
response.end(icon);
|
||||
return;
|
||||
}
|
||||
pageHits += 1;
|
||||
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
response.end('<!doctype html><title>Example Studio</title><link rel="icon" type="image/png" href="/mark.png"><h1>Example Studio</h1>');
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const url = `http://127.0.0.1:${address.port}/studio`;
|
||||
const capture = await runCliAsync(['brands', 'capture', url, '--json'], { ARCHIFY_BRAND_ALLOW_PRIVATE: '1' });
|
||||
assert.equal(capture.status, 0, capture.stderr || capture.stdout);
|
||||
const receipt = JSON.parse(capture.stdout);
|
||||
assert.equal(receipt.ok, true);
|
||||
assert.deepEqual(receipt.brand, {
|
||||
url,
|
||||
sha256: createHash('sha256').update(icon).digest('hex'),
|
||||
});
|
||||
|
||||
const input = writeFixture('architecture', 'captured-link', receipt.brand);
|
||||
const rendered = await renderAsync('architecture', input, 'captured-link', { ARCHIFY_BRAND_ALLOW_PRIVATE: '1' });
|
||||
assert.equal(rendered.status, 0, rendered.stderr || rendered.stdout);
|
||||
assert.equal(pageHits, 2);
|
||||
assert.equal(iconHits, 2);
|
||||
assert.match(rendered.html, /data-brand-status="captured"/);
|
||||
assert.match(rendered.html, /data:image\/png;base64,/);
|
||||
assert.match(rendered.html, new RegExp(`data-brand-sha256="${receipt.brand.sha256}"`));
|
||||
assert.ok(!rendered.html.includes('http://127.0.0.1') || rendered.html.includes('data-node-brand-source='));
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('a pinned brand fails closed when the remote icon digest changes', async () => {
|
||||
const firstIcon = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
|
||||
const changedIcon = Buffer.from(firstIcon);
|
||||
changedIcon[45] ^= 1;
|
||||
let iconHits = 0;
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.url === '/mark.png') {
|
||||
iconHits += 1;
|
||||
response.writeHead(200, { 'content-type': 'image/png' });
|
||||
response.end(iconHits === 1 ? firstIcon : changedIcon);
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
response.end('<!doctype html><title>Changing site</title><link rel="icon" type="image/png" href="/mark.png">');
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const url = `http://127.0.0.1:${address.port}/`;
|
||||
const capture = await runCliAsync(['brands', 'capture', url, '--json'], { ARCHIFY_BRAND_ALLOW_PRIVATE: '1' });
|
||||
assert.equal(capture.status, 0, capture.stderr || capture.stdout);
|
||||
const brand = JSON.parse(capture.stdout).brand;
|
||||
const input = writeFixture('architecture', 'changed-digest', brand);
|
||||
const result = await runCliAsync(['validate', 'architecture', input, '--json'], { ARCHIFY_BRAND_ALLOW_PRIVATE: '1' });
|
||||
assert.equal(result.status, 1, result.stderr || result.stdout);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.diagnostics.filter((entry) => entry.code === 'brand/digest-mismatch').length, 1);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('a pinned brand keeps identical artifact metadata when the remote page title changes', async () => {
|
||||
const icon = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
|
||||
let pageHits = 0;
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.url === '/mark.png') {
|
||||
response.writeHead(200, { 'content-type': 'image/png' });
|
||||
response.end(icon);
|
||||
return;
|
||||
}
|
||||
pageHits += 1;
|
||||
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
response.end(`<!doctype html><title>Title ${pageHits}</title><link rel="icon" type="image/png" href="/mark.png">`);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const url = `http://127.0.0.1:${address.port}/`;
|
||||
const capture = await runCliAsync(['brands', 'capture', url, '--json'], { ARCHIFY_BRAND_ALLOW_PRIVATE: '1' });
|
||||
assert.equal(capture.status, 0, capture.stderr || capture.stdout);
|
||||
const input = writeFixture('architecture', 'stable-title', JSON.parse(capture.stdout).brand);
|
||||
const first = await renderAsync('architecture', input, 'stable-title-first', { ARCHIFY_BRAND_ALLOW_PRIVATE: '1' });
|
||||
const second = await renderAsync('architecture', input, 'stable-title-second', { ARCHIFY_BRAND_ALLOW_PRIVATE: '1' });
|
||||
assert.equal(first.status, 0, first.stderr || first.stdout);
|
||||
assert.equal(second.status, 0, second.stderr || second.stdout);
|
||||
assert.equal(first.html, second.html);
|
||||
assert.match(first.html, /data-brand-title="127\.0\.0\.1"/);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('each prepare call rechecks pinned remote bytes instead of trusting a process-wide cache', async () => {
|
||||
const firstIcon = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
|
||||
const changedIcon = Buffer.from(firstIcon);
|
||||
changedIcon[45] ^= 1;
|
||||
let iconHits = 0;
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.url === '/mark.png') {
|
||||
iconHits += 1;
|
||||
response.writeHead(200, { 'content-type': 'image/png' });
|
||||
response.end(iconHits === 1 ? firstIcon : changedIcon);
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
response.end('<!doctype html><title>Changing site</title><link rel="icon" type="image/png" href="/mark.png">');
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const priorAllowPrivate = process.env.ARCHIFY_BRAND_ALLOW_PRIVATE;
|
||||
process.env.ARCHIFY_BRAND_ALLOW_PRIVATE = '1';
|
||||
try {
|
||||
const address = server.address();
|
||||
const diagram = {
|
||||
components: [{
|
||||
id: 'remote',
|
||||
label: 'Remote',
|
||||
brand: {
|
||||
url: `http://127.0.0.1:${address.port}/`,
|
||||
sha256: createHash('sha256').update(firstIcon).digest('hex'),
|
||||
},
|
||||
}],
|
||||
};
|
||||
await prepareDiagramBrandMarks('architecture', diagram);
|
||||
await assert.rejects(
|
||||
prepareDiagramBrandMarks('architecture', diagram),
|
||||
/brand digest changed/i,
|
||||
);
|
||||
assert.equal(iconHits, 2);
|
||||
} finally {
|
||||
if (priorAllowPrivate === undefined) delete process.env.ARCHIFY_BRAND_ALLOW_PRIVATE;
|
||||
else process.env.ARCHIFY_BRAND_ALLOW_PRIVATE = priorAllowPrivate;
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('capture blocks IPv4-mapped IPv6 loopback and metadata destinations before connecting', async () => {
|
||||
for (const url of [
|
||||
'http://[::ffff:127.0.0.1]/',
|
||||
'http://[::ffff:169.254.169.254]/',
|
||||
'http://[::192.168.1.1]/',
|
||||
'http://[64:ff9b::c0a8:101]/',
|
||||
'http://[2002:c0a8:0101::]/',
|
||||
'http://[ff02::1]/',
|
||||
'http://192.0.2.1/',
|
||||
'http://198.51.100.1/',
|
||||
'http://203.0.113.1/',
|
||||
]) {
|
||||
const capture = await runCliAsync(['brands', 'capture', url, '--json']);
|
||||
assert.notEqual(capture.status, 0, `${url}: ${capture.stderr || capture.stdout}`);
|
||||
assert.match(capture.stderr, /private brand links are not fetched/i, url);
|
||||
}
|
||||
});
|
||||
|
||||
test('address classification blocks exact reserved ranges without rejecting adjacent public IPv4 space', () => {
|
||||
for (const address of ['192.0.2.1', '192.88.99.1', '198.51.100.1', '203.0.113.1']) {
|
||||
assert.equal(isPrivateBrandAddress(address), true, address);
|
||||
}
|
||||
for (const address of ['192.2.1.1', '192.88.98.1', '198.51.99.1', '203.0.112.1']) {
|
||||
assert.equal(isPrivateBrandAddress(address), false, address);
|
||||
}
|
||||
});
|
||||
|
||||
test('capture requires the standard port for the selected web protocol', async () => {
|
||||
for (const url of [
|
||||
'http://brand.example.invalid:443/',
|
||||
'https://brand.example.invalid:80/',
|
||||
'https://github.com:80/',
|
||||
]) {
|
||||
const capture = await runCliAsync(['brands', 'capture', url, '--json']);
|
||||
assert.notEqual(capture.status, 0, capture.stdout);
|
||||
assert.match(capture.stderr, /standard web port/i, url);
|
||||
}
|
||||
});
|
||||
|
||||
test('capture rejects credentials even when the URL domain matches a bundled preset', async () => {
|
||||
const capture = await runCliAsync(['brands', 'capture', 'https://user:secret@github.com/', '--json']);
|
||||
assert.notEqual(capture.status, 0, capture.stdout);
|
||||
assert.match(capture.stderr, /cannot contain credentials/i);
|
||||
});
|
||||
|
||||
test('rendering many pinned brands limits concurrent remote capture work', async () => {
|
||||
const icon = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
|
||||
const sha256 = createHash('sha256').update(icon).digest('hex');
|
||||
let active = 0;
|
||||
let maximumActive = 0;
|
||||
const server = http.createServer((request, response) => {
|
||||
active += 1;
|
||||
maximumActive = Math.max(maximumActive, active);
|
||||
setTimeout(() => {
|
||||
if (request.url.endsWith('.png')) {
|
||||
response.writeHead(200, { 'content-type': 'image/png' });
|
||||
active -= 1;
|
||||
response.end(icon);
|
||||
} else {
|
||||
const suffix = request.url.replace(/^\/site-/, '');
|
||||
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
active -= 1;
|
||||
response.end(`<!doctype html><title>Site ${suffix}</title><link rel="icon" type="image/png" href="/mark-${suffix}.png">`);
|
||||
}
|
||||
}, 40);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const input = writeFixture('architecture', 'bounded-capture', 'openai', (diagram) => {
|
||||
diagram.components.forEach((node, index) => {
|
||||
node.brand = {
|
||||
url: `http://127.0.0.1:${address.port}/site-${index}`,
|
||||
sha256,
|
||||
};
|
||||
});
|
||||
});
|
||||
const rendered = await renderAsync('architecture', input, 'bounded-capture', { ARCHIFY_BRAND_ALLOW_PRIVATE: '1' });
|
||||
assert.equal(rendered.status, 0, rendered.stderr || rendered.stdout);
|
||||
assert.ok(maximumActive <= 3, `expected at most 3 concurrent requests, observed ${maximumActive}`);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('rendering many pinned brands shares one diagram capture deadline', async () => {
|
||||
const icon = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
|
||||
const sha256 = createHash('sha256').update(icon).digest('hex');
|
||||
const server = http.createServer((_request, response) => {
|
||||
setTimeout(() => {
|
||||
response.writeHead(200, { 'content-type': 'image/png' });
|
||||
response.end(icon);
|
||||
}, 80);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const input = writeFixture('architecture', 'diagram-deadline', 'openai', (diagram) => {
|
||||
diagram.components.forEach((node, index) => {
|
||||
node.brand = {
|
||||
url: `http://127.0.0.1:${address.port}/mark-${index}.png`,
|
||||
sha256,
|
||||
};
|
||||
});
|
||||
});
|
||||
const rendered = await renderAsync('architecture', input, 'diagram-deadline', {
|
||||
ARCHIFY_BRAND_ALLOW_PRIVATE: '1',
|
||||
ARCHIFY_BRAND_CAPTURE_TIMEOUT_MS: '100',
|
||||
});
|
||||
assert.notEqual(rendered.status, 0, rendered.stdout);
|
||||
assert.match(rendered.stderr, /abort|timed? ?out|timeout/i);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('capture applies one total deadline across the page and icon requests', async () => {
|
||||
const icon = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
|
||||
const server = http.createServer((request, response) => {
|
||||
setTimeout(() => {
|
||||
if (request.url === '/mark.png') {
|
||||
response.writeHead(200, { 'content-type': 'image/png' });
|
||||
response.end(icon);
|
||||
} else {
|
||||
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
response.end('<!doctype html><title>Slow site</title><link rel="icon" type="image/png" href="/mark.png">');
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const capture = await runCliAsync(
|
||||
['brands', 'capture', `http://127.0.0.1:${address.port}/`, '--json'],
|
||||
{
|
||||
ARCHIFY_BRAND_ALLOW_PRIVATE: '1',
|
||||
ARCHIFY_BRAND_CAPTURE_TIMEOUT_MS: '150',
|
||||
},
|
||||
);
|
||||
assert.notEqual(capture.status, 0, capture.stdout);
|
||||
assert.match(capture.stderr, /abort|timed? ?out|timeout/i);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('capture rejects remote SVG even when the document appears passive', async () => {
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.url === '/mark.svg') {
|
||||
response.writeHead(200, { 'content-type': 'image/svg+xml' });
|
||||
response.end('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="24" height="24"/></svg>');
|
||||
return;
|
||||
}
|
||||
if (request.url === '/favicon.ico') {
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
response.end('<!doctype html><title>SVG mark</title><link rel="icon" type="image/svg+xml" href="/mark.svg">');
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const capture = await runCliAsync(
|
||||
['brands', 'capture', `http://127.0.0.1:${address.port}/studio`, '--json'],
|
||||
{ ARCHIFY_BRAND_ALLOW_PRIVATE: '1' },
|
||||
);
|
||||
assert.notEqual(capture.status, 0, capture.stdout);
|
||||
assert.match(capture.stderr, /unsupported brand image type image\/svg\+xml/i);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('unsupported SVG declarations cannot crowd out the favicon fallback', async () => {
|
||||
const icon = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
|
||||
let fallbackHits = 0;
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.url === '/favicon.ico') {
|
||||
fallbackHits += 1;
|
||||
response.writeHead(200, { 'content-type': 'image/png' });
|
||||
response.end(icon);
|
||||
return;
|
||||
}
|
||||
if (request.url?.endsWith('.svg')) {
|
||||
response.writeHead(200, { 'content-type': 'image/svg+xml' });
|
||||
response.end('<svg xmlns="http://www.w3.org/2000/svg"/>');
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
response.end(`<!doctype html><title>Fallback mark</title>${Array.from(
|
||||
{ length: 6 },
|
||||
(_, index) => `<link rel="icon" type="image/svg+xml" href="/mark-${index}.svg">`,
|
||||
).join('')}`);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const capture = await runCliAsync(
|
||||
['brands', 'capture', `http://127.0.0.1:${address.port}/`, '--json'],
|
||||
{ ARCHIFY_BRAND_ALLOW_PRIVATE: '1' },
|
||||
);
|
||||
assert.equal(capture.status, 0, capture.stderr || capture.stdout);
|
||||
const receipt = JSON.parse(capture.stdout);
|
||||
assert.equal(receipt.evidence.contentType, 'image/png');
|
||||
assert.equal(fallbackHits, 1);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('capture rejects an image whose bytes do not match its declared media type', async () => {
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'image/png' });
|
||||
response.end('<html>not a png</html>');
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const capture = await runCliAsync(
|
||||
['brands', 'capture', `http://127.0.0.1:${address.port}/mark.png`, '--json'],
|
||||
{ ARCHIFY_BRAND_ALLOW_PRIVATE: '1' },
|
||||
);
|
||||
assert.notEqual(capture.status, 0, capture.stdout);
|
||||
assert.match(capture.stderr, /do(?:es)? not match image\/png/i);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('capture rejects a truncated PNG that contains only its signature', async () => {
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'image/png' });
|
||||
response.end(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const capture = await runCliAsync(
|
||||
['brands', 'capture', `http://127.0.0.1:${address.port}/mark.png`, '--json'],
|
||||
{ ARCHIFY_BRAND_ALLOW_PRIVATE: '1' },
|
||||
);
|
||||
assert.notEqual(capture.status, 0, capture.stdout);
|
||||
assert.match(capture.stderr, /do(?:es)? not match image\/png/i);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown preset names fail with a repairable public CLI diagnostic', () => {
|
||||
const input = writeFixture('architecture', 'unknown-preset', 'open-aii');
|
||||
const result = spawnSync(process.execPath, [cli, 'validate', 'architecture', input, '--json'], {
|
||||
cwd: skillRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 1, result.stderr || result.stdout);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.ok, false);
|
||||
assert.ok(receipt.diagnostics.some((entry) => entry.code === 'brand/unknown'));
|
||||
assert.ok(receipt.diagnostics.some((entry) => entry.supportedFixes.some((fix) => fix.includes('archify brands'))));
|
||||
});
|
||||
|
||||
test('viewer exposes brand identity to Passport and Finder while keeping source beacons clear', () => {
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets', 'template.html'), 'utf8');
|
||||
assert.match(template, /id="focus-brand" data-passport="brand" hidden/);
|
||||
assert.match(template, /node\.getAttribute\('data-node-brand'\)/);
|
||||
assert.match(template, /brandOffset = node\.hasAttribute\('data-node-brand'\) \? 24 : 0/);
|
||||
assert.match(template, /sourceSearch \+ ' ' \+ text\)\.toLowerCase\(\) \+ ' ' \+ brand\.toLowerCase\(\)/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,157 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-chapter-delta-preview-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
const PROOF_CASES = [
|
||||
'agent-tool-call.workflow.json',
|
||||
'production-deployment.architecture.json',
|
||||
'cache-miss-request.sequence.json',
|
||||
'release-delivery.workflow.json',
|
||||
'incident-response.workflow.json',
|
||||
'product-analytics.dataflow.json',
|
||||
'async-job-roundtrip.sequence.json',
|
||||
'event-stream.dataflow.json',
|
||||
'agent-run.lifecycle.json',
|
||||
'deployment-release.lifecycle.json',
|
||||
'web-app.architecture.json',
|
||||
];
|
||||
|
||||
function render(mode) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', CASES[mode]),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
function delta(previous, destination) {
|
||||
const previousIds = new Set(previous);
|
||||
const destinationIds = new Set(destination);
|
||||
return {
|
||||
stay: previous.filter((id) => destinationIds.has(id)),
|
||||
enter: destination.filter((id) => !previousIds.has(id)),
|
||||
leave: previous.filter((id) => !destinationIds.has(id)),
|
||||
};
|
||||
}
|
||||
|
||||
test('all five renderers inherit one viewer-only static Chapter Delta Preview', () => {
|
||||
for (const mode of Object.keys(CASES)) {
|
||||
const html = render(mode);
|
||||
assert.match(html, /function chapterDelta\(previous, destination\)/, mode);
|
||||
assert.match(html, /className = 'guided-view-chapter-delta'/, mode);
|
||||
assert.match(html, /svg\.setAttribute\('data-chapter-preview', views\[index\]\.id\)/, mode);
|
||||
assert.match(html, /data-chapter-preview-role/, mode);
|
||||
assert.match(html, /transition: none !important/, mode);
|
||||
assert.doesNotMatch(canonicalSvg(html), /data-chapter-preview|data-chapter-preview-role/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('exact stable-ID set math powers truthful counts and the existing handoff', () => {
|
||||
const html = render('workflow');
|
||||
assert.match(html, /stay: previousFocus\.filter\(function \(id\) \{ return destinationIds\[id\]; \}\)/);
|
||||
assert.match(html, /enter: destinationFocus\.filter\(function \(id\) \{ return !previousIds\[id\]; \}\)/);
|
||||
assert.match(html, /leave: previousFocus\.filter\(function \(id\) \{ return !destinationIds\[id\]; \}\)/);
|
||||
assert.match(html, /var delta = chapterDelta\(previous, destination\);[\s\S]*?chapterAnchor\(previous, destination, outgoingBeatIndex, delta\)/);
|
||||
assert.match(html, /var compact = '=' \+ delta\.stay\.length \+ ' \+' \+ delta\.enter\.length \+ ' \\u2212' \+ delta\.leave\.length/);
|
||||
assert.match(html, /viewerText\('viewer\.guided\.chapter\.delta\.aria'/);
|
||||
assert.doesNotMatch(html, /inferChapterDelta|matchChapterLabel|nearestKind/);
|
||||
|
||||
let adjacent = 0;
|
||||
let shared = 0;
|
||||
for (const file of PROOF_CASES) {
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', file), 'utf8'));
|
||||
const views = doc.meta.views;
|
||||
for (let index = 1; index < views.length; index += 1) {
|
||||
adjacent += 1;
|
||||
if (delta(views[index - 1].focus, views[index].focus).stay.length) shared += 1;
|
||||
}
|
||||
}
|
||||
assert.equal(adjacent, 22);
|
||||
assert.equal(shared, 19);
|
||||
|
||||
const workflow = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', CASES.workflow), 'utf8'));
|
||||
const [first, second, third] = workflow.meta.views;
|
||||
assert.deepEqual(delta(first.focus, second.focus), {
|
||||
stay: ['router', 'approval'],
|
||||
enter: ['blocked', 'retry'],
|
||||
leave: ['user', 'chat', 'planner', 'tool', 'external', 'final'],
|
||||
});
|
||||
assert.deepEqual(delta(second.focus, third.focus), {
|
||||
stay: [],
|
||||
enter: ['external', 'store', 'trace'],
|
||||
leave: ['router', 'approval', 'blocked', 'retry'],
|
||||
});
|
||||
});
|
||||
|
||||
test('pointer and keyboard inspect while touch and native activation still commit directly', () => {
|
||||
const html = render('architecture');
|
||||
assert.match(html, /chapterList\.addEventListener\('pointerover'/);
|
||||
assert.match(html, /!hoverCapable\(\) \|\| event\.pointerType === 'touch'/);
|
||||
assert.match(html, /setChapterPreviewIntent\('pointer', chapterButtons\.indexOf\(button\)\)/);
|
||||
assert.match(html, /chapterIndex\.addEventListener\('focusin',[\s\S]*?setChapterPreviewIntent\('focus'/);
|
||||
assert.match(html, /event\.key === 'Escape' && activePreviewIndex >= 0[\s\S]*?event\.stopImmediatePropagation\(\)[\s\S]*?clearChapterPreview\(\{ clearIntents: true \}\)/);
|
||||
assert.match(html, /chapterList\.addEventListener\('click',[\s\S]*?activateById/);
|
||||
assert.match(html, /button\.type = 'button'/);
|
||||
assert.doesNotMatch(html, /firstTap|secondTap|longpress|long-press/);
|
||||
|
||||
const previewRuntime = html.slice(
|
||||
html.indexOf('function chapterPreviewBlocked()'),
|
||||
html.indexOf('function sharePlaybackRequested()'),
|
||||
);
|
||||
assert.doesNotMatch(previewRuntime, /Archify\.view\.|updateUrl\(|Archify\.focus\.|renderStoryTrail\(/);
|
||||
});
|
||||
|
||||
test('latest intent, stronger owners, playback, and lifecycle cleanup remain bounded', () => {
|
||||
const html = render('lifecycle');
|
||||
assert.match(html, /var previewGeneration = 0/);
|
||||
assert.match(html, /right\.generation - left\.generation/);
|
||||
assert.match(html, /\[pointerPreviewIntent, focusPreviewIntent\]/);
|
||||
assert.match(html, /document\.hidden \|\| playing \|\| currentHandoff/);
|
||||
assert.match(html, /data-route-picking'[\s\S]*?data-route-active/);
|
||||
assert.match(html, /data-lens-active'[\s\S]*?data-legend-preview-active/);
|
||||
assert.match(html, /data-relationship-preview-active'[\s\S]*?data-intent-trace-active/);
|
||||
assert.match(html, /if \(playing\) pausePlayback\(\)/);
|
||||
assert.match(html, /Archify\.motionGovernor\.claim\('chapter-preview'/);
|
||||
assert.match(html, /Archify\.motionGovernor\.release\(token\)/);
|
||||
assert.match(html, /handoff\.resolve[\s\S]*?syncChapterPreview\(\)/);
|
||||
assert.match(html, /visibilitychange'[\s\S]*?clearChapterPreview/);
|
||||
assert.match(html, /beforeprint'[\s\S]*?clearChapterPreview[\s\S]*?settleHandoff/);
|
||||
});
|
||||
|
||||
test('mobile, Still, embed, print, and canonical exports keep the preview viewer-only', () => {
|
||||
const html = render('sequence');
|
||||
assert.match(html, /\.guided-view-chapter \{ min-height: 2\.75rem; \}/);
|
||||
assert.match(html, /flex: 0 0 min\(14rem, 78vw\)/);
|
||||
assert.match(html, /\.guided-view-chapter-delta\[hidden\] \{ display: none; \}/);
|
||||
assert.match(html, /document\.documentElement\.getAttribute\('data-embed'\) === 'true'/);
|
||||
assert.match(html, /prefers-reduced-motion: reduce[\s\S]*?svg\[data-chapter-preview\]/);
|
||||
assert.match(html, /svg\[data-chapter-preview\] \[data-node-id\],[\s\S]*?opacity: 1 !important; filter: none !important/);
|
||||
assert.match(html, /clone\.removeAttribute\('data-chapter-preview'\)/);
|
||||
assert.match(html, /clone\.querySelectorAll\('\[data-chapter-preview-role\]'\)/);
|
||||
assert.match(html, /!clone\.hasAttribute\('data-chapter-preview'\)/);
|
||||
assert.match(html, /canonicalStateClean/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,97 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-chapter-handoff-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', CASES[mode]),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all five renderers inherit one viewer-only Shared Anchor Chapter Handoff', () => {
|
||||
for (const mode of Object.keys(CASES)) {
|
||||
const html = render(mode);
|
||||
assert.match(html, /id="guided-view-handoff" hidden aria-hidden="true"/, mode);
|
||||
assert.match(html, /function beginHandoff\(previousIndex, nextIndex, previous, destination, outgoingBeatIndex, reason\)/, mode);
|
||||
assert.match(html, /data-chapter-handoff-overlay/, mode);
|
||||
assert.match(html, /ring\.setAttribute\('class', 'chapter-handoff-anchor'\)/, mode);
|
||||
assert.doesNotMatch(canonicalSvg(html), /data-chapter-handoff|data-chapter-role|chapter-handoff-anchor/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('anchor selection uses exact stable-id intersection with deterministic outgoing priority', () => {
|
||||
const html = render('workflow');
|
||||
assert.match(html, /function chapterDelta\(previous, destination\)/);
|
||||
assert.match(html, /function chapterAnchor\(previous, destination, outgoingBeatIndex, delta\)/);
|
||||
assert.match(html, /delta\.stay\.forEach\(function \(id\) \{ stayIds\[id\] = true; \}\)/);
|
||||
assert.match(html, /var activeBeat = outgoingBeatIndex >= 0 \? previous\.focus\[outgoingBeatIndex\] : ''/);
|
||||
assert.match(html, /activeBeat && stayIds\[activeBeat\]/);
|
||||
assert.match(html, /for \(var index = previous\.focus\.length - 1; index >= 0; index -= 1\)/);
|
||||
assert.match(html, /if \(stayIds\[previous\.focus\[index\]\]\) return previous\.focus\[index\]/);
|
||||
assert.doesNotMatch(html, /inferChapterAnchor|matchChapterLabel|nearestKind/);
|
||||
});
|
||||
|
||||
test('handoff holds one truthful anchor then settles through one finite camera transaction', () => {
|
||||
const html = render('architecture');
|
||||
assert.match(html, /handoff\.mode = 'settling'/);
|
||||
assert.match(html, /setTimeout\(startCamera, 110\)/);
|
||||
assert.match(html, /duration: 420/);
|
||||
assert.match(html, /requestAnimationFrame\(step\)/);
|
||||
assert.match(html, /var eased = 1 - Math\.pow\(1 - fraction, 3\)/);
|
||||
assert.match(html, /Archify\.motionGovernor\.claim\('handoff'/);
|
||||
assert.match(html, /Archify\.motionGovernor\.release\(handoff\.ownerToken\)/);
|
||||
assert.match(html, /handoffReceipt\.textContent = viewerText\('viewer\.guided\.handoff'/);
|
||||
assert.doesNotMatch(html, /chapter-handoff[^\n]+infinite/);
|
||||
});
|
||||
|
||||
test('latest intent, manual takeover, Still, reduced motion, and hidden pages cleanly settle', () => {
|
||||
const html = render('lifecycle');
|
||||
assert.match(html, /cancelHandoff\('replaced'\)/);
|
||||
assert.match(html, /cameraTransaction\.cancel\(reason \|\| 'cancelled', commitTarget === true\)/);
|
||||
assert.match(html, /transaction\.settled/);
|
||||
assert.match(html, /currentHandoff !== handoff/);
|
||||
assert.match(html, /Archify\.guidedViews\.cancelHandoff\(reason \|\| 'manual'\)/);
|
||||
assert.match(html, /settleHandoff\(systemPaused \? 'reduced-motion' : \(hasSuspension\(\) \? 'hidden' : 'still'\)\)/);
|
||||
assert.match(html, /settleHandoff\('hidden'\)/);
|
||||
assert.match(html, /settleHandoff\('reduced-motion'\)/);
|
||||
assert.match(html, /window\.addEventListener\('beforeprint',[\s\S]*?clearChapterPreview[\s\S]*?settleHandoff\('print'\)/);
|
||||
});
|
||||
|
||||
test('mobile, embed, print, and canonical exports keep strict static boundaries', () => {
|
||||
const html = render('sequence');
|
||||
assert.match(html, /document\.documentElement\.getAttribute\('data-embed'\) === 'true'/);
|
||||
assert.match(html, /cameraReceipt\(\{ scrollLeft: target \}/);
|
||||
assert.match(html, /behavior: instant \? 'auto' : 'smooth'/);
|
||||
assert.match(html, /\.chapter-handoff-overlay \{ display: none !important; \}/);
|
||||
assert.match(html, /clone\.removeAttribute\('data-chapter-handoff'\)/);
|
||||
assert.match(html, /clone\.removeAttribute\('data-chapter-anchor'\)/);
|
||||
assert.match(html, /clone\.querySelectorAll\('\[data-chapter-handoff-overlay\]'\)/);
|
||||
assert.match(html, /clone\.querySelectorAll\('\[data-chapter-role\]'\)/);
|
||||
assert.match(html, /!clone\.hasAttribute\('data-chapter-handoff'\)/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-chapter-rail-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', CASES[mode]),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all guided renderers expose one runtime-built named chapter rail', () => {
|
||||
for (const mode of Object.keys(CASES)) {
|
||||
const html = render(mode);
|
||||
assert.match(html, /<nav class="guided-view-index" id="guided-view-index" aria-label="Story chapters">/, mode);
|
||||
assert.match(html, /<ol class="guided-view-chapters" id="guided-view-chapters"><\/ol>/, mode);
|
||||
assert.match(html, /function buildChapterIndex\(\)/, mode);
|
||||
assert.match(html, /views\.forEach\(function \(view, index\)/, mode);
|
||||
assert.match(html, /position\.textContent = \(index \+ 1 < 10 \? '0' : ''\) \+ \(index \+ 1\)/, mode);
|
||||
assert.match(html, /title\.textContent = view\.label/, mode);
|
||||
assert.match(html, /stops\.textContent = viewerCount\('viewer\.guided\.chapter\.stop', view\.focus\.length\)/, mode);
|
||||
assert.doesNotMatch(canonicalSvg(html), /guided-view-chapter|data-chapter-position/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('chapter rail delegates selection and mirrors the existing activeIndex owner', () => {
|
||||
const html = render('architecture');
|
||||
assert.match(html, /activateById\(button\.getAttribute\('data-guided-view-id'\)\)/);
|
||||
assert.match(html, /var current = index === activeIndex/);
|
||||
assert.match(html, /activeIndex < 0 \? 'available' : \(current \? 'current' : \(index < activeIndex \? 'before' : 'after'\)\)/);
|
||||
assert.match(html, /button\.setAttribute\('aria-current', 'step'\)/);
|
||||
assert.match(html, /button\.removeAttribute\('aria-current'\)/);
|
||||
assert.match(html, /syncChapterIndex\(\);[\s\S]*renderShareCue\(\)/);
|
||||
assert.doesNotMatch(html, /selectedChapter|visitedChapters|completedChapters/);
|
||||
});
|
||||
|
||||
test('chapter rail is keyboard-first and pauses playback on reader takeover', () => {
|
||||
const html = render('workflow');
|
||||
assert.match(html, /chapterIndex\.addEventListener\('focusin',[\s\S]*if \(playing\) pausePlayback\(\)/);
|
||||
assert.match(html, /event\.key === 'ArrowRight'/);
|
||||
assert.match(html, /event\.key === 'ArrowLeft'/);
|
||||
assert.match(html, /event\.key === 'Home'/);
|
||||
assert.match(html, /event\.key === 'End'/);
|
||||
assert.match(html, /focusChapterButton\(target\)/);
|
||||
assert.match(html, /button\.type = 'button'/);
|
||||
assert.doesNotMatch(html, /role="tab"|role="tabpanel"/);
|
||||
});
|
||||
|
||||
test('chapter rail has positional, touch, mobile, motion, embed, and print boundaries', () => {
|
||||
const html = render('lifecycle');
|
||||
assert.match(html, /\.guided-view-chapter\[data-chapter-position="current"\][\s\S]*border: 2px solid/);
|
||||
assert.match(html, /data-chapter-position="before"[\s\S]*border-style: solid/);
|
||||
assert.match(html, /data-chapter-position="after"[\s\S]*border-style: dashed/);
|
||||
assert.match(html, /\.guided-view-chapter \{ min-height: 2\.75rem; \}/);
|
||||
assert.match(html, /scroll-snap-type: x proximity/);
|
||||
assert.match(html, /flex: 0 0 min\(14rem, 78vw\)/);
|
||||
assert.match(html, /behavior: 'auto'/);
|
||||
assert.match(html, /prefers-reduced-motion: reduce[\s\S]*\.guided-view-chapter \{ transition: none !important; \}/);
|
||||
assert.match(html, /html\[data-embed="true"\] \.guided-views \{ display: none !important; \}/);
|
||||
assert.match(html, /\.toolbar, \.diagram-nav, \.focus-chip, \.guided-views, \.archify-toast, \.no-print \{ display: none !important; \}/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,244 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { stageCleanSkill } from '../../scripts/stage-clean-skill.mjs';
|
||||
|
||||
const stagerPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../scripts/stage-clean-skill.mjs');
|
||||
|
||||
function git(root, args) {
|
||||
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
}
|
||||
|
||||
function write(root, relative, content, mode = null) {
|
||||
const target = path.join(root, relative);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, content);
|
||||
if (mode !== null) fs.chmodSync(target, mode);
|
||||
return target;
|
||||
}
|
||||
|
||||
function repositoryFixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-clean-stage-'));
|
||||
write(root, 'archify/package.json', JSON.stringify({
|
||||
name: 'archify-fixture',
|
||||
scripts: { test: 'node --test' },
|
||||
devDependencies: { ajv: '1.0.0' },
|
||||
}));
|
||||
write(root, 'archify/package-lock.json', '{}\n');
|
||||
write(root, 'archify/skill-release.json', '{}\n');
|
||||
write(root, 'archify/scripts/check-update.mjs', 'export {};\n');
|
||||
write(root, 'archify/scripts/update-contract.mjs', 'export {};\n');
|
||||
write(root, 'archify/renderers/shared/generated-validators.mjs', 'export {};\n');
|
||||
write(root, 'archify/test/repository-only.test.mjs', 'throw new Error();\n');
|
||||
git(root, ['init']);
|
||||
return root;
|
||||
}
|
||||
|
||||
test('clean staging preserves index modes and strips repository-only package metadata', () => {
|
||||
const root = repositoryFixture();
|
||||
const destination = path.join(root, 'staged-skill');
|
||||
try {
|
||||
write(root, 'archify/bin/executable.mjs', '#!/usr/bin/env node\n', 0o755);
|
||||
write(root, 'archify/runtime/test/required.dat', 'runtime fixture\n');
|
||||
git(root, ['add', 'archify']);
|
||||
|
||||
stageCleanSkill({ repoRoot: root, destination });
|
||||
|
||||
assert.equal(fs.statSync(path.join(destination, 'bin', 'executable.mjs')).mode & 0o777, 0o755);
|
||||
assert.equal(fs.existsSync(path.join(destination, 'test')), false);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(destination, 'runtime', 'test', 'required.dat'), 'utf8'),
|
||||
'runtime fixture\n',
|
||||
'only the repository-root test tree is excluded',
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(destination, 'package-lock.json')), false);
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(destination, 'package.json'), 'utf8'));
|
||||
assert.equal(Object.hasOwn(packageJson, 'scripts'), false);
|
||||
assert.equal(Object.hasOwn(packageJson, 'devDependencies'), false);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clean staging rejects a symlink in a tracked file ancestor before copying bytes', (t) => {
|
||||
const root = repositoryFixture();
|
||||
const destination = path.join(root, 'staged-skill');
|
||||
try {
|
||||
const runtime = path.join(root, 'archify', 'runtime');
|
||||
write(root, 'archify/runtime/payload.txt', 'tracked fixture\n');
|
||||
git(root, ['add', 'archify']);
|
||||
fs.rmSync(runtime, { recursive: true });
|
||||
const external = path.join(root, 'outside-runtime');
|
||||
write(root, 'outside-runtime/payload.txt', 'external secret\n');
|
||||
try {
|
||||
fs.symlinkSync(external, runtime, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
} catch (error) {
|
||||
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
||||
t.skip(`symlinks unavailable: ${error.code}`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
assert.throws(
|
||||
() => stageCleanSkill({ repoRoot: root, destination }),
|
||||
/refusing to package path through symlink: archify\/runtime/,
|
||||
);
|
||||
assert.equal(fs.existsSync(destination), false);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clean staging rejects tracked symlinks before reading through them', (t) => {
|
||||
const root = repositoryFixture();
|
||||
const destination = path.join(root, 'staged-skill');
|
||||
try {
|
||||
const external = write(root, 'outside.txt', 'private fixture\n');
|
||||
const linked = path.join(root, 'archify', 'linked.txt');
|
||||
try {
|
||||
fs.symlinkSync(external, linked);
|
||||
} catch (error) {
|
||||
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
||||
t.skip(`symlinks unavailable: ${error.code}`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
git(root, ['add', 'archify']);
|
||||
|
||||
assert.throws(
|
||||
() => stageCleanSkill({ repoRoot: root, destination }),
|
||||
/refusing to package tracked symlink: archify\/linked\.txt/,
|
||||
);
|
||||
assert.equal(fs.existsSync(destination), false);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clean staging snapshots unstaged tracked bytes before a source ancestor can be swapped', (t) => {
|
||||
const root = repositoryFixture();
|
||||
const destination = path.join(root, 'staged-skill');
|
||||
const runtime = path.join(root, 'archify', 'runtime');
|
||||
const external = path.join(root, 'outside-runtime');
|
||||
const originalMkdirSync = fs.mkdirSync;
|
||||
let swapped = false;
|
||||
try {
|
||||
const payload = write(root, 'archify/runtime/payload.txt', 'indexed fixture\n');
|
||||
write(root, 'outside-runtime/payload.txt', 'external secret\n');
|
||||
git(root, ['add', 'archify']);
|
||||
fs.writeFileSync(payload, 'unstaged working-tree fixture\n');
|
||||
|
||||
const probe = path.join(root, 'symlink-probe');
|
||||
try {
|
||||
fs.symlinkSync(external, probe, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
fs.rmSync(probe, { force: true });
|
||||
} catch (error) {
|
||||
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
||||
t.skip(`symlinks unavailable: ${error.code}`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
fs.mkdirSync = function swapSourceAfterSnapshot(target, ...args) {
|
||||
const result = originalMkdirSync.call(fs, target, ...args);
|
||||
if (!swapped && path.resolve(target) === path.resolve(destination)) {
|
||||
fs.rmSync(runtime, { recursive: true });
|
||||
fs.symlinkSync(external, runtime, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
swapped = true;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
stageCleanSkill({ repoRoot: root, destination });
|
||||
|
||||
assert.equal(swapped, true, 'the deterministic ancestor-swap attack must run');
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(destination, 'runtime', 'payload.txt'), 'utf8'),
|
||||
'unstaged working-tree fixture\n',
|
||||
'staging keeps the tracked working-tree snapshot and never follows the replacement ancestor',
|
||||
);
|
||||
} finally {
|
||||
fs.mkdirSync = originalMkdirSync;
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clean staging rejects a source ancestor swapped during preflight traversal', (t) => {
|
||||
const root = repositoryFixture();
|
||||
const destination = path.join(root, 'staged-skill');
|
||||
const runtime = path.join(root, 'archify', 'runtime');
|
||||
const external = path.join(root, 'outside-runtime');
|
||||
const originalLstatSync = fs.lstatSync;
|
||||
let swapped = false;
|
||||
try {
|
||||
write(root, 'archify/runtime/payload.txt', 'tracked fixture\n');
|
||||
write(root, 'outside-runtime/payload.txt', 'external secret\n');
|
||||
git(root, ['add', 'archify']);
|
||||
const canonicalRuntime = path.join(fs.realpathSync(root), 'archify', 'runtime');
|
||||
|
||||
const probe = path.join(root, 'symlink-probe');
|
||||
try {
|
||||
fs.symlinkSync(external, probe, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
fs.rmSync(probe, { force: true });
|
||||
} catch (error) {
|
||||
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
||||
t.skip(`symlinks unavailable: ${error.code}`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
fs.lstatSync = function swapSourceBetweenAncestorAndLeaf(target, ...args) {
|
||||
const metadata = originalLstatSync.call(fs, target, ...args);
|
||||
if (!swapped && path.resolve(target) === canonicalRuntime) {
|
||||
// Guard before mutation: recursive removal can re-enter the patched
|
||||
// lstatSync implementation on Linux.
|
||||
swapped = true;
|
||||
fs.rmSync(runtime, { recursive: true });
|
||||
fs.symlinkSync(external, runtime, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
}
|
||||
return metadata;
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => stageCleanSkill({ repoRoot: root, destination }),
|
||||
/(?:tracked package path changed before it could be read: archify\/|tracked package input is missing or unreadable: archify\/runtime\/payload\.txt)/,
|
||||
);
|
||||
assert.equal(swapped, true, 'the deterministic mid-preflight ancestor swap must run');
|
||||
assert.equal(fs.existsSync(destination), false);
|
||||
} finally {
|
||||
fs.lstatSync = originalLstatSync;
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clean staging reports the Git spawn error when Git cannot start', () => {
|
||||
const root = repositoryFixture();
|
||||
const destination = path.join(root, 'staged-skill');
|
||||
try {
|
||||
git(root, ['add', 'archify']);
|
||||
const result = spawnSync(process.execPath, [
|
||||
stagerPath,
|
||||
'--root', root,
|
||||
'--dest', destination,
|
||||
], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, PATH: '' },
|
||||
});
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /unable to enumerate tracked Archify files: .*ENOENT/);
|
||||
assert.doesNotMatch(result.stderr, /tracked Archify paths must be valid UTF-8/);
|
||||
assert.equal(fs.existsSync(destination), false);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,785 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { extractSvgs, parseXml } from './helpers/xml.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-cli-'));
|
||||
const cli = path.join(skillRoot, 'bin/archify.mjs');
|
||||
|
||||
function run(args, options = {}) {
|
||||
return spawnSync(process.execPath, [cli, ...args], {
|
||||
cwd: options.cwd || skillRoot,
|
||||
encoding: 'utf8',
|
||||
env: options.env || process.env,
|
||||
});
|
||||
}
|
||||
|
||||
function sha256(file) {
|
||||
return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
}
|
||||
|
||||
function makeFakeOpeners(name, { exitCode = 0 } = {}) {
|
||||
const bin = path.join(tmp, name);
|
||||
const log = path.join(bin, 'open-log.json');
|
||||
fs.mkdirSync(bin, { recursive: true });
|
||||
const source = `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const target = process.argv[process.argv.length - 1];
|
||||
fs.writeFileSync(process.env.ARCHIFY_TEST_OPEN_LOG, JSON.stringify({
|
||||
argv: process.argv.slice(2),
|
||||
target,
|
||||
existed: fs.existsSync(target),
|
||||
}));
|
||||
process.exit(${exitCode});
|
||||
`;
|
||||
for (const command of ['open', 'xdg-open']) {
|
||||
const executable = path.join(bin, command);
|
||||
fs.writeFileSync(executable, source);
|
||||
fs.chmodSync(executable, 0o755);
|
||||
}
|
||||
return {
|
||||
log,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}${path.delimiter}${process.env.PATH || ''}`,
|
||||
ARCHIFY_TEST_OPEN_LOG: log,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function copyInstalledSkill(target) {
|
||||
fs.cpSync(skillRoot, target, {
|
||||
recursive: true,
|
||||
filter(source) {
|
||||
const rel = path.relative(skillRoot, source);
|
||||
return rel !== 'node_modules' && !rel.startsWith(`node_modules${path.sep}`)
|
||||
&& rel !== 'test' && !rel.startsWith(`test${path.sep}`)
|
||||
// Another test creates this short-lived directory under skillRoot so
|
||||
// Ajv resolves from the checkout. Never copy a concurrently removed
|
||||
// test fixture into an installed-skill simulation.
|
||||
&& !rel.startsWith('.validator-check-');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('cli: help lists commands and diagram types', () => {
|
||||
const result = run(['--help']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /archify render <type>/);
|
||||
assert.match(result.stdout, /archify compare architecture <base\.json> <head\.json>/);
|
||||
assert.match(result.stdout, /archify deliver <type>/);
|
||||
assert.match(result.stdout, /archify preview <type>/);
|
||||
assert.match(result.stdout, /archify visual-check <output\.html>/);
|
||||
assert.match(result.stdout, /--open/);
|
||||
assert.match(result.stdout, /--repo-root path \(architecture only\)/);
|
||||
assert.match(result.stdout, /archify guide \[scenario or question\]/);
|
||||
assert.match(result.stdout, /archify doctor/);
|
||||
assert.match(result.stdout, /archify demo \[output-directory\]/);
|
||||
assert.match(result.stdout, /architecture, workflow, sequence, dataflow, lifecycle/);
|
||||
});
|
||||
|
||||
test('cli: doctor reports a complete installation is ready', () => {
|
||||
const result = run(['doctor']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /\[ok\] Node\.js v\d+/);
|
||||
assert.match(result.stdout, /\[ok\] Core template/);
|
||||
assert.match(result.stdout, /\[ok\] Example renderer/);
|
||||
assert.match(result.stdout, /\[ok\] Live preview runtime/);
|
||||
assert.match(result.stdout, /\[ok\] Scenario recipe guide/);
|
||||
assert.match(result.stdout, /\[ok\] Progressive authoring references/);
|
||||
assert.match(result.stdout, /\[ok\] Architecture compare runtime and proof fixtures/);
|
||||
assert.match(result.stdout, /\[ok\] Standalone schema validators/);
|
||||
assert.match(result.stdout, /\[ok\] architecture renderer, schema, and example/);
|
||||
assert.match(result.stdout, /\[ok\] lifecycle renderer, schema, and example/);
|
||||
assert.match(result.stdout, /Archify is ready\./);
|
||||
});
|
||||
|
||||
test('cli: doctor identifies an incomplete installation', () => {
|
||||
const incompleteRoot = path.join(tmp, 'incomplete-skill');
|
||||
const incompleteBin = path.join(incompleteRoot, 'bin');
|
||||
fs.mkdirSync(incompleteBin, { recursive: true });
|
||||
fs.copyFileSync(cli, path.join(incompleteBin, 'archify.mjs'));
|
||||
|
||||
const result = spawnSync(process.execPath, [path.join(incompleteBin, 'archify.mjs'), 'doctor'], {
|
||||
cwd: incompleteRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stdout, /\[missing\] Core template/);
|
||||
assert.match(result.stdout, /\[missing\] Scenario recipe guide/);
|
||||
assert.match(result.stdout, /\[missing\] workflow renderer, schema, and example/);
|
||||
assert.match(result.stderr, /Archify is not ready: \d+ required files? missing\./);
|
||||
});
|
||||
|
||||
test('cli: doctor rejects a corrupt standalone validator', () => {
|
||||
const corruptRoot = path.join(tmp, 'corrupt-skill');
|
||||
copyInstalledSkill(corruptRoot);
|
||||
fs.writeFileSync(path.join(corruptRoot, 'renderers/shared/generated-validators.mjs'), 'export const workflow = ;\n');
|
||||
|
||||
const result = spawnSync(process.execPath, [path.join(corruptRoot, 'bin/archify.mjs'), 'doctor'], {
|
||||
cwd: corruptRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stdout, /\[invalid\] Standalone schema validators/);
|
||||
assert.match(result.stderr, /Archify is not ready: 1 runtime check failed\./);
|
||||
});
|
||||
|
||||
test('cli: examples renders from an installed skill', () => {
|
||||
const installedRoot = path.join(tmp, 'installed-skill');
|
||||
copyInstalledSkill(installedRoot);
|
||||
|
||||
const result = spawnSync(process.execPath, [path.join(installedRoot, 'bin/archify.mjs'), 'examples'], {
|
||||
cwd: installedRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
for (const output of [
|
||||
'workflow-agent-tool-call-rendered.html',
|
||||
'sequence-cache-miss-request.html',
|
||||
'dataflow-product-analytics.html',
|
||||
'lifecycle-agent-run.html',
|
||||
'web-app-rendered.html',
|
||||
]) {
|
||||
assert.equal(fs.existsSync(path.join(installedRoot, 'examples', output)), true, output);
|
||||
}
|
||||
});
|
||||
|
||||
test('cli: guide lists all scenario recipes by diagram type', () => {
|
||||
const result = run(['guide']);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Archify scenario recipes \(11\)/);
|
||||
for (const type of ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']) {
|
||||
assert.match(result.stdout, new RegExp(`\\[${type}\\]`));
|
||||
}
|
||||
});
|
||||
|
||||
test('cli: guide recommends a scenario as structured json', () => {
|
||||
const result = run(['guide', 'Show an API request with Redis cache miss', '--json']);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.lang, 'en');
|
||||
assert.equal(parsed.confidence, 'high');
|
||||
assert.equal(parsed.recommendation.id, 'api-request');
|
||||
assert.equal(parsed.recommendation.type, 'sequence');
|
||||
});
|
||||
|
||||
test('cli: guide detects Chinese and explains the recommendation boundary', () => {
|
||||
const result = run(['guide', '展示 Kafka topic 消费者组和死信队列']);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /推荐: 事件流拓扑 \[dataflow\]/);
|
||||
assert.match(result.stdout, /不要这样用:/);
|
||||
assert.match(result.stdout, /必须包含:/);
|
||||
assert.match(result.stdout, /可直接复制的提示词:/);
|
||||
});
|
||||
|
||||
test('cli: guide works from an installed skill without node_modules', () => {
|
||||
const installedRoot = path.join(tmp, 'installed-guide-skill');
|
||||
copyInstalledSkill(installedRoot);
|
||||
const installedCli = path.join(installedRoot, 'bin/archify.mjs');
|
||||
|
||||
const result = spawnSync(process.execPath, [installedCli, 'guide', 'incident-runbook', '--json'], {
|
||||
cwd: installedRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(JSON.parse(result.stdout).recommendation.id, 'incident-runbook');
|
||||
});
|
||||
|
||||
test('cli: demo creates a ready-to-open diagram in a chosen directory', () => {
|
||||
const outputDirectory = path.join(tmp, 'my-demo');
|
||||
const output = path.join(outputDirectory, 'archify-demo.html');
|
||||
const result = run(['demo', outputDirectory]);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(fs.existsSync(output), true);
|
||||
assert.match(fs.readFileSync(output, 'utf8'), /Sample Web App Diagram/);
|
||||
assert.match(result.stdout, new RegExp(`Demo ready: ${output.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
|
||||
assert.match(result.stdout, /Next: open the HTML in your browser/);
|
||||
assert.match(result.stdout, /archify render architecture/);
|
||||
});
|
||||
|
||||
test('cli: demo defaults to the current directory', () => {
|
||||
const workingDirectory = path.join(tmp, 'default-demo');
|
||||
fs.mkdirSync(workingDirectory);
|
||||
const result = run(['demo'], { cwd: workingDirectory });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(fs.existsSync(path.join(workingDirectory, 'archify-demo.html')), true);
|
||||
});
|
||||
|
||||
test('cli: render writes a diagram html file', () => {
|
||||
const out = path.join(tmp, 'workflow.html');
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const result = run(['render', 'workflow', input, out]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(fs.existsSync(out), true);
|
||||
assert.match(fs.readFileSync(out, 'utf8'), /Agent Tool Call Workflow/);
|
||||
});
|
||||
|
||||
test('cli: visual-check returns a skipped receipt with exit 2 when Chrome is unavailable', () => {
|
||||
const out = path.join(tmp, 'visual-check-skipped.html');
|
||||
fs.writeFileSync(out, '<!doctype html><html><body>delivered</body></html>');
|
||||
const missingChrome = path.join(tmp, 'missing-chrome');
|
||||
const result = run(['visual-check', out, '--json'], {
|
||||
env: { ...process.env, ARCHIFY_CHROME: missingChrome },
|
||||
});
|
||||
|
||||
assert.equal(result.status, 2, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.status, 'skipped');
|
||||
assert.equal(receipt.visualReview, 'pending');
|
||||
assert.equal(receipt.chrome.status, 'unavailable');
|
||||
assert.equal(fs.existsSync(out.replace(/\.html$/, '.visual-check.json')), true);
|
||||
});
|
||||
|
||||
test('cli: deliver atomically writes a checked artifact and structured receipt', () => {
|
||||
const out = path.join(tmp, 'delivered-workflow.html');
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const result = run(['deliver', 'workflow', input, out, '--quality', 'showcase', '--json']);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(fs.existsSync(out), true);
|
||||
assert.match(fs.readFileSync(out, 'utf8'), /Agent Tool Call Workflow/);
|
||||
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.schemaVersion, 1);
|
||||
assert.equal(receipt.ok, true);
|
||||
assert.equal(receipt.command, 'deliver');
|
||||
assert.equal(receipt.type, 'workflow');
|
||||
assert.equal(receipt.input, input);
|
||||
assert.equal(receipt.output, out);
|
||||
assert.deepEqual(receipt.specification, {
|
||||
sha256: sha256(input),
|
||||
bytes: fs.statSync(input).size,
|
||||
});
|
||||
assert.match(receipt.artifact.sha256, /^[a-f0-9]{64}$/);
|
||||
assert.equal(receipt.artifact.sha256, sha256(out));
|
||||
assert.equal(receipt.artifact.bytes, fs.statSync(out).size);
|
||||
assert.deepEqual(receipt.validation, {
|
||||
checksPassed: 9,
|
||||
checkCount: 9,
|
||||
compositionProfile: 'showcase',
|
||||
compositionStatus: 'pass',
|
||||
errors: 0,
|
||||
warnings: 0,
|
||||
});
|
||||
assert.equal('open' in receipt, false);
|
||||
});
|
||||
|
||||
test('cli: deliver --open launches only the committed absolute artifact as one argument', {
|
||||
skip: process.platform === 'win32',
|
||||
}, () => {
|
||||
const fake = makeFakeOpeners('successful-open');
|
||||
const out = path.join(tmp, `-复杂 path 'quoted'`, 'verified diagram.html');
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const result = run(['deliver', 'workflow', input, out, '--open', '--json'], { env: fake.env });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.deepEqual(receipt.open, {
|
||||
requested: true,
|
||||
status: 'opened',
|
||||
target: out,
|
||||
method: process.platform === 'darwin' ? 'open' : 'xdg-open',
|
||||
});
|
||||
const invocation = JSON.parse(fs.readFileSync(fake.log, 'utf8'));
|
||||
assert.equal(invocation.existed, true, 'the opener must run after the atomic commit');
|
||||
assert.deepEqual(invocation.argv, [out]);
|
||||
assert.equal(invocation.target, out);
|
||||
assert.equal(fs.existsSync(out), true);
|
||||
});
|
||||
|
||||
test('cli: opener failure does not invalidate a verified delivery or pollute json stdout', {
|
||||
skip: process.platform === 'win32',
|
||||
}, () => {
|
||||
const fake = makeFakeOpeners('failed-open', { exitCode: 17 });
|
||||
const out = path.join(tmp, 'open-failure-preserves-delivery.html');
|
||||
const input = path.join(skillRoot, 'examples/web-app.architecture.json');
|
||||
const result = run(['deliver', 'architecture', input, out, '--open', '--json'], { env: fake.env });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.ok, true);
|
||||
assert.equal(receipt.open.status, 'failed');
|
||||
assert.equal(receipt.open.target, out);
|
||||
assert.match(result.stderr, /Could not open the verified artifact/);
|
||||
assert.match(result.stderr, new RegExp(out.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
assert.equal(fs.existsSync(out), true);
|
||||
assert.equal(receipt.artifact.sha256, sha256(out));
|
||||
});
|
||||
|
||||
test('cli: deliver failure never invokes the optional opener', {
|
||||
skip: process.platform === 'win32',
|
||||
}, () => {
|
||||
const fake = makeFakeOpeners('never-open');
|
||||
const input = path.join(tmp, 'invalid-open-delivery.json');
|
||||
fs.writeFileSync(input, '{broken json');
|
||||
const out = path.join(tmp, 'must-not-open.html');
|
||||
const result = run(['deliver', 'architecture', input, out, '--open', '--json'], { env: fake.env });
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(JSON.parse(result.stdout).stage, 'input');
|
||||
assert.equal(fs.existsSync(fake.log), false);
|
||||
assert.equal(fs.existsSync(out), false);
|
||||
});
|
||||
|
||||
test('cli: a missing optional opener module preserves verified delivery with a fallback receipt', () => {
|
||||
const installedRoot = path.join(tmp, 'missing-open-module-skill');
|
||||
copyInstalledSkill(installedRoot);
|
||||
const installedCli = path.join(installedRoot, 'bin/archify.mjs');
|
||||
fs.rmSync(path.join(installedRoot, 'bin/open-artifact.mjs'));
|
||||
const input = path.join(installedRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const out = path.join(tmp, 'missing-open-module-delivery.html');
|
||||
|
||||
const result = spawnSync(process.execPath, [installedCli, 'deliver', 'workflow', input, out, '--open', '--json'], {
|
||||
cwd: installedRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.ok, true);
|
||||
assert.deepEqual(receipt.open, {
|
||||
requested: true,
|
||||
status: 'unsupported',
|
||||
target: out,
|
||||
method: null,
|
||||
});
|
||||
assert.match(result.stderr, /Open it manually/);
|
||||
assert.equal(receipt.artifact.sha256, sha256(out));
|
||||
});
|
||||
|
||||
test('cli: deliver preserves the renderer default output contract', () => {
|
||||
const workingDirectory = path.join(tmp, 'delivery-default-output');
|
||||
fs.mkdirSync(workingDirectory, { recursive: true });
|
||||
const input = path.join(workingDirectory, 'source.architecture.json');
|
||||
const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/web-app.architecture.json'), 'utf8'));
|
||||
source.meta.output = 'verified-default.html';
|
||||
fs.writeFileSync(input, JSON.stringify(source));
|
||||
|
||||
const result = run(['deliver', 'architecture', input, '--json'], { cwd: workingDirectory });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.output, path.join(fs.realpathSync(workingDirectory), 'verified-default.html'));
|
||||
assert.equal(fs.existsSync(receipt.output), true);
|
||||
});
|
||||
|
||||
test('cli: deliver works from an installed skill without node_modules', () => {
|
||||
const installedRoot = path.join(tmp, 'installed-deliver-skill');
|
||||
copyInstalledSkill(installedRoot);
|
||||
const installedCli = path.join(installedRoot, 'bin/archify.mjs');
|
||||
const cases = [
|
||||
['architecture-boundaries', 'architecture', 'production-deployment.architecture.json'],
|
||||
['architecture-issue-110', 'architecture', 'brand-aware-delivery.architecture.json'],
|
||||
['workflow', 'workflow', 'agent-tool-call.workflow.json'],
|
||||
['sequence', 'sequence', 'cache-miss-request.sequence.json'],
|
||||
['dataflow', 'dataflow', 'product-analytics.dataflow.json'],
|
||||
['lifecycle', 'lifecycle', 'agent-run.lifecycle.json'],
|
||||
];
|
||||
|
||||
for (const [label, type, example] of cases) {
|
||||
const input = path.join(installedRoot, 'examples', example);
|
||||
const out = path.join(tmp, `installed-${label}-delivery.html`);
|
||||
const result = spawnSync(process.execPath, [installedCli, 'deliver', type, input, out, '--json'], {
|
||||
cwd: installedRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, `${label}: ${result.stderr}`);
|
||||
assert.equal(JSON.parse(result.stdout).validation.checkCount, 9, label);
|
||||
assert.equal(fs.existsSync(out), true, label);
|
||||
const extracted = extractSvgs(fs.readFileSync(out, 'utf8'));
|
||||
assert.equal(extracted.direct.length, 1, `${label}: expected one delivered SVG`);
|
||||
assert.doesNotThrow(
|
||||
() => parseXml(extracted.direct[0]),
|
||||
`${label}: delivered SVG must be well-formed XML`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('cli: deliver XML guard parses markup instead of scanning attribute-like text', () => {
|
||||
assert.doesNotThrow(() => parseXml(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" aria-label="mentions data-node-label safely"/>',
|
||||
));
|
||||
assert.throws(
|
||||
() => parseXml('<svg xmlns="http://www.w3.org/2000/svg" data-node-label></svg>'),
|
||||
/attribute without value/i,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseXml('<svg xmlns="http://www.w3.org/2000/svg"><g></svg>'),
|
||||
/unexpected close tag/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('cli: preview runs from an installed skill without node_modules and exits cleanly', { timeout: 30000 }, async () => {
|
||||
const installedRoot = path.join(tmp, 'installed-preview-skill');
|
||||
copyInstalledSkill(installedRoot);
|
||||
const installedCli = path.join(installedRoot, 'bin/archify.mjs');
|
||||
const input = path.join(installedRoot, 'examples/web-app.architecture.json');
|
||||
const output = path.join(tmp, 'installed-preview.html');
|
||||
const child = spawn(process.execPath, [installedCli, 'preview', 'architecture', input, output, '--quality', 'showcase', '--no-open'], {
|
||||
cwd: installedRoot,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
|
||||
let previewUrl;
|
||||
const started = Date.now();
|
||||
while (!previewUrl && Date.now() - started < 8000) {
|
||||
previewUrl = stdout.match(/preview (http:\/\/127\.0\.0\.1:\d+\/)/)?.[1];
|
||||
if (!previewUrl) await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
assert.ok(previewUrl, `preview URL missing; stdout=${stdout}; stderr=${stderr}`);
|
||||
|
||||
let state;
|
||||
while (Date.now() - started < 15000) {
|
||||
state = await fetch(new URL('/state', previewUrl)).then((response) => response.json());
|
||||
if (state.status === 'verified') break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
assert.equal(state?.status, 'verified', `preview did not verify; stdout=${stdout}; stderr=${stderr}`);
|
||||
assert.equal(state.revision, 1);
|
||||
assert.equal(fs.existsSync(output), true);
|
||||
|
||||
child.kill('SIGTERM');
|
||||
const exit = await new Promise((resolve) => child.once('close', (code, signal) => resolve({ code, signal })));
|
||||
assert.deepEqual(exit, { code: 0, signal: null });
|
||||
assert.match(stdout, /stopping preview/);
|
||||
await assert.rejects(fetch(previewUrl));
|
||||
assert.deepEqual(fs.readdirSync(path.dirname(output)).filter((name) => name.startsWith('.archify-preview-')), []);
|
||||
});
|
||||
|
||||
test('cli: deliver preserves the previous artifact when the final check fails', () => {
|
||||
const installedRoot = path.join(tmp, 'broken-deliver-skill');
|
||||
copyInstalledSkill(installedRoot);
|
||||
const installedCli = path.join(installedRoot, 'bin/archify.mjs');
|
||||
const templatePath = path.join(installedRoot, 'assets/template.html');
|
||||
const template = fs.readFileSync(templatePath, 'utf8');
|
||||
fs.writeFileSync(templatePath, template.replace('</body>', '<svg aria-label="accidental second svg"></svg>\n</body>'));
|
||||
|
||||
const input = path.join(installedRoot, 'examples/web-app.architecture.json');
|
||||
const out = path.join(tmp, 'preserved-delivery.html');
|
||||
const trustedPriorArtifact = '<!doctype html><title>trusted prior artifact</title>\n';
|
||||
fs.writeFileSync(out, trustedPriorArtifact);
|
||||
|
||||
const result = spawnSync(process.execPath, [installedCli, 'deliver', 'architecture', input, out, '--json'], {
|
||||
cwd: installedRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.stage, 'check');
|
||||
assert.equal(failure.diagnostics[0].code, 'artifact/single-svg');
|
||||
assert.equal(failure.diagnostics[0].subject.check, 'single_svg');
|
||||
assert.ok(failure.diagnostics[0].supportedFixes.some((fix) => fix.includes('exactly one diagram SVG')));
|
||||
assert.equal(failure.checker.checks.find((entry) => entry.name === 'single_svg').ok, false);
|
||||
assert.equal(fs.readFileSync(out, 'utf8'), trustedPriorArtifact);
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(path.dirname(out)).filter((name) => name.includes('.archify-delivery-')),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('cli: deliver reports renderer failure as json and preserves the previous artifact', () => {
|
||||
const input = path.join(tmp, 'invalid-delivery.workflow.json');
|
||||
const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/agent-tool-call.workflow.json'), 'utf8'));
|
||||
source.nodes[0].unexpected = true;
|
||||
fs.writeFileSync(input, JSON.stringify(source));
|
||||
|
||||
const out = path.join(tmp, 'renderer-failure-preserved.html');
|
||||
const trustedPriorArtifact = '<!doctype html><title>last known good</title>\n';
|
||||
fs.writeFileSync(out, trustedPriorArtifact);
|
||||
|
||||
const result = run(['deliver', 'workflow', input, out, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.stage, 'render');
|
||||
assert.match(failure.error, /schema validation failed/i);
|
||||
assert.equal(fs.readFileSync(out, 'utf8'), trustedPriorArtifact);
|
||||
});
|
||||
|
||||
test('cli: deliver reports unreadable input as json without touching the target', () => {
|
||||
const input = path.join(tmp, 'malformed-delivery.json');
|
||||
fs.writeFileSync(input, '{not valid json');
|
||||
const out = path.join(tmp, 'malformed-input-preserved.html');
|
||||
const trustedPriorArtifact = '<!doctype html><title>still trusted</title>\n';
|
||||
fs.writeFileSync(out, trustedPriorArtifact);
|
||||
|
||||
const result = run(['deliver', 'architecture', input, out, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.stage, 'input');
|
||||
assert.match(failure.error, /Could not read delivery input/);
|
||||
assert.equal(fs.readFileSync(out, 'utf8'), trustedPriorArtifact);
|
||||
});
|
||||
|
||||
test('cli: invalid source output metadata still fails inside the renderer', () => {
|
||||
const workingDirectory = path.join(tmp, 'invalid-output-metadata');
|
||||
fs.mkdirSync(workingDirectory, { recursive: true });
|
||||
const input = path.join(workingDirectory, 'source.architecture.json');
|
||||
const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/web-app.architecture.json'), 'utf8'));
|
||||
source.meta.output = 17;
|
||||
fs.writeFileSync(input, JSON.stringify(source));
|
||||
const out = path.join(workingDirectory, 'architecture.html');
|
||||
const trustedPriorArtifact = '<!doctype html><title>metadata did not replace me</title>\n';
|
||||
fs.writeFileSync(out, trustedPriorArtifact);
|
||||
|
||||
const result = run(['deliver', 'architecture', input, '--json'], { cwd: workingDirectory });
|
||||
assert.equal(result.status, 1);
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.stage, 'render');
|
||||
assert.match(failure.error, /schema validation failed/i);
|
||||
assert.equal(fs.readFileSync(out, 'utf8'), trustedPriorArtifact);
|
||||
});
|
||||
|
||||
test('cli: deliver reports commit failure without a false success receipt', () => {
|
||||
const input = path.join(skillRoot, 'examples/web-app.architecture.json');
|
||||
const outputDirectory = path.join(tmp, 'commit-target-is-a-directory');
|
||||
fs.mkdirSync(outputDirectory, { recursive: true });
|
||||
|
||||
const result = run(['deliver', 'architecture', input, outputDirectory, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.stage, 'commit');
|
||||
assert.match(failure.error, /Could not commit verified delivery/);
|
||||
assert.equal(fs.statSync(outputDirectory).isDirectory(), true);
|
||||
assert.equal(fs.readdirSync(outputDirectory).length, 0);
|
||||
});
|
||||
|
||||
test('cli: deliver reports preparation failure as json without touching the blocker', () => {
|
||||
const input = path.join(skillRoot, 'examples/web-app.architecture.json');
|
||||
const blockingFile = path.join(tmp, 'delivery-parent-is-a-file');
|
||||
fs.writeFileSync(blockingFile, 'do not replace me');
|
||||
const out = path.join(blockingFile, 'cannot-write.html');
|
||||
|
||||
const result = run(['deliver', 'architecture', input, out, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.stage, 'prepare');
|
||||
assert.match(failure.error, /Could not create delivery directory/);
|
||||
assert.equal(fs.readFileSync(blockingFile, 'utf8'), 'do not replace me');
|
||||
});
|
||||
|
||||
test('cli: check validates rendered html', () => {
|
||||
const out = path.join(tmp, 'workflow-check.html');
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
assert.equal(run(['render', 'workflow', input, out]).status, 0);
|
||||
|
||||
const result = run(['check', out]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /"ok": true/);
|
||||
});
|
||||
|
||||
test('cli: validate emits structured json without keeping html output', () => {
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const before = new Set(fs.readdirSync(tmp));
|
||||
const result = run(['validate', 'workflow', input, '--json']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.type, 'workflow');
|
||||
assert.equal(parsed.checks.length, 9);
|
||||
assert.equal(parsed.composition.profile, 'showcase');
|
||||
assert.deepEqual(parsed.composition.summary, { errors: 0, warnings: 0 });
|
||||
assert.equal(parsed.composition.metrics.containerBorderRuns, 0);
|
||||
assert.equal(parsed.composition.metrics.ambiguousCorridors, 0);
|
||||
assert.deepEqual(new Set(fs.readdirSync(tmp)), before);
|
||||
});
|
||||
|
||||
test('cli: validate JSON exposes only the primary v1 column-capacity diagnostic', () => {
|
||||
const input = path.join(tmp, 'pinned-column-capacity.workflow.json');
|
||||
fs.writeFileSync(input, `${JSON.stringify({
|
||||
schema_version: 1,
|
||||
diagram_type: 'workflow',
|
||||
meta: {
|
||||
title: 'Pinned issue 126 diagnostic boundary',
|
||||
viewBox: [720, 400],
|
||||
legend: { mode: 'hidden' },
|
||||
},
|
||||
lanes: [{ id: 'main', label: 'Main' }],
|
||||
nodes: [
|
||||
{ id: 'a', lane: 'main', col: 1, type: 'backend', label: 'A' },
|
||||
{ id: 'b', lane: 'main', col: 2, type: 'backend', label: 'B' },
|
||||
],
|
||||
edges: [{
|
||||
id: 'ab',
|
||||
from: 'a',
|
||||
to: 'b',
|
||||
fromSide: 'top',
|
||||
toSide: 'top',
|
||||
via: [[220, 60], [300, 60]],
|
||||
}],
|
||||
}, null, 2)}\n`);
|
||||
|
||||
const result = run(['validate', 'workflow', input, '--json'], {
|
||||
env: { ...process.env, ARCHIFY_DIAGNOSTIC_FORMAT: 'json' },
|
||||
});
|
||||
|
||||
assert.equal(result.status, 1, result.stderr || result.stdout);
|
||||
assert.equal(result.stderr, '');
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.command, 'validate');
|
||||
assert.equal(failure.stage, 'render');
|
||||
assert.equal(failure.type, 'workflow');
|
||||
assert.equal(failure.diagnostics.length, 1, JSON.stringify(failure.diagnostics, null, 2));
|
||||
const [primary] = failure.diagnostics;
|
||||
assert.equal(primary.code, 'workflow/column-capacity');
|
||||
assert.equal(primary.subject.edge, 'ab');
|
||||
assert.equal(primary.subject.fromCol, 1);
|
||||
assert.equal(primary.subject.toCol, 2);
|
||||
assert.ok(primary.supportedFixes.length > 0);
|
||||
assert.ok(failure.diagnostics.every(({ code }) => (
|
||||
code !== 'workflow/explicit-pin-conflict' && code !== 'workflow/viewbox-capacity'
|
||||
)));
|
||||
});
|
||||
|
||||
test('cli: --quality overrides the source profile for render, validate, and deliver', () => {
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const out = path.join(tmp, 'workflow-standard.html');
|
||||
const rendered = run(['render', 'workflow', input, out, '--quality', 'standard']);
|
||||
assert.equal(rendered.status, 0, rendered.stderr);
|
||||
assert.match(fs.readFileSync(out, 'utf8'), /data-quality-profile="standard"/);
|
||||
|
||||
const validated = run(['validate', 'workflow', input, '--quality=standard', '--json']);
|
||||
assert.equal(validated.status, 0, validated.stderr);
|
||||
assert.equal(JSON.parse(validated.stdout).composition.profile, 'standard');
|
||||
|
||||
const deliveredOut = path.join(tmp, 'workflow-delivered-standard.html');
|
||||
const delivered = run(['deliver', 'workflow', input, deliveredOut, '--quality=standard', '--json']);
|
||||
assert.equal(delivered.status, 0, delivered.stderr);
|
||||
assert.equal(JSON.parse(delivered.stdout).validation.compositionProfile, 'standard');
|
||||
});
|
||||
|
||||
test('cli: rejects an unknown quality profile', () => {
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const result = run(['validate', 'workflow', input, '--quality', 'hero']);
|
||||
assert.equal(result.status, 2);
|
||||
assert.match(result.stderr, /Expected standard or showcase/);
|
||||
});
|
||||
|
||||
test('cli: rejects a quality flag without a value', () => {
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
for (const args of [
|
||||
['validate', 'workflow', input, '--json', '--quality'],
|
||||
['validate', 'workflow', input, '--quality', '--json'],
|
||||
['validate', 'workflow', input, '--quality='],
|
||||
]) {
|
||||
const result = run(args);
|
||||
assert.equal(result.status, 2);
|
||||
assert.match(result.stderr, /--quality requires standard or showcase/);
|
||||
}
|
||||
});
|
||||
|
||||
test('cli: validate rejects unknown flags, layout-json assignment typos, and extra positionals', () => {
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const cases = [
|
||||
{
|
||||
args: ['validate', 'workflow', input, '--layout-json', '--bogus'],
|
||||
pattern: /Unknown validate option "--bogus"/,
|
||||
},
|
||||
{
|
||||
args: ['validate', 'workflow', input, '--layout-json=true'],
|
||||
pattern: /Unknown validate option "--layout-json=true"/,
|
||||
},
|
||||
{
|
||||
args: ['validate', 'workflow', input, '--layout-json=true', '--json'],
|
||||
pattern: /Unknown validate option "--layout-json=true"/,
|
||||
},
|
||||
{
|
||||
args: ['validate', 'workflow', input, 'unexpected-output.html', '--layout-json'],
|
||||
pattern: /Usage:/,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { args, pattern } of cases) {
|
||||
const result = run(args);
|
||||
assert.equal(result.status, 2, `${args.join(' ')}\n${result.stderr}\n${result.stdout}`);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.match(result.stderr, pattern);
|
||||
}
|
||||
});
|
||||
|
||||
test('cli: inspect emits architecture layout json', () => {
|
||||
const input = path.resolve(skillRoot, '../examples/archify-repo-grid.architecture.json');
|
||||
const result = run(['inspect', 'architecture', input]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.diagram_type, 'architecture');
|
||||
assert.equal(parsed.layout.mode, 'grid');
|
||||
assert.ok(parsed.components.length >= 5);
|
||||
assert.ok(parsed.connections.length >= 1);
|
||||
});
|
||||
|
||||
test('cli: inspect remains architecture-only while workflow uses validate --layout-json', () => {
|
||||
const input = path.join(skillRoot, 'examples', 'agent-tool-call.workflow.json');
|
||||
const result = run(['inspect', 'workflow', input]);
|
||||
assert.equal(result.status, 2);
|
||||
assert.match(result.stderr, /inspect is currently supported for architecture diagrams only/);
|
||||
assert.equal(result.stdout, '');
|
||||
});
|
||||
|
||||
test('cli: validate returns renderer errors for bad input', () => {
|
||||
const input = path.join(tmp, 'bad.workflow.json');
|
||||
const validateTmp = path.join(tmp, 'validate-failure-tmp');
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/agent-tool-call.workflow.json'), 'utf8'));
|
||||
doc.edges[0].to = 'ghost';
|
||||
fs.writeFileSync(input, JSON.stringify(doc));
|
||||
fs.mkdirSync(validateTmp);
|
||||
|
||||
const result = run(['validate', 'workflow', input], {
|
||||
env: { ...process.env, TMPDIR: validateTmp },
|
||||
});
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /unknown target "ghost"/);
|
||||
assert.deepEqual(fs.readdirSync(validateTmp), []);
|
||||
});
|
||||
|
||||
test('cli: validate rejects an unknown type without leaking a temp directory', () => {
|
||||
const validateTmp = path.join(tmp, 'validate-unknown-type-tmp');
|
||||
fs.mkdirSync(validateTmp);
|
||||
|
||||
const result = run(['validate', 'unknown', 'ignored.json'], {
|
||||
env: {
|
||||
...process.env,
|
||||
TMPDIR: validateTmp,
|
||||
TMP: validateTmp,
|
||||
TEMP: validateTmp,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, 2);
|
||||
assert.match(result.stderr, /Unknown diagram type "unknown"/);
|
||||
assert.deepEqual(fs.readdirSync(validateTmp), []);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(here, '../..');
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('showcase intake requires reproducible proof, redaction, and explicit publication permission', () => {
|
||||
const template = read('.github/ISSUE_TEMPLATE/showcase.yml');
|
||||
|
||||
for (const field of [
|
||||
'id: diagram_type',
|
||||
'id: archify_version',
|
||||
'id: agent',
|
||||
'id: model',
|
||||
'id: prompt',
|
||||
'id: source_json',
|
||||
'id: artifact',
|
||||
'id: validation_receipt',
|
||||
'id: visual_review',
|
||||
'id: sensitive_data',
|
||||
'id: sharing_rights',
|
||||
'id: public_permission',
|
||||
]) {
|
||||
assert.match(template, new RegExp(field), field);
|
||||
}
|
||||
assert.match(template, /access tokens/i);
|
||||
assert.match(template, /personal or customer data/i);
|
||||
assert.match(template, /repository, documentation, gallery, and project website/i);
|
||||
assert.match(template, /required:\s*true/g);
|
||||
|
||||
const submissionUrl = 'https://github.com/tt-a1i/archify/issues/new?template=showcase.yml';
|
||||
for (const readme of ['README.md', 'README_EN.md', 'README_ZH.md']) {
|
||||
assert.match(read(readme), new RegExp(submissionUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), `${readme}: direct showcase link`);
|
||||
}
|
||||
});
|
||||
|
||||
test('bug intake captures a minimal deterministic reproduction before visual diagnosis', () => {
|
||||
const template = read('.github/ISSUE_TEMPLATE/bug-report.yml');
|
||||
|
||||
for (const field of [
|
||||
'id: archify_version',
|
||||
'id: install_method',
|
||||
'id: diagram_type',
|
||||
'id: command',
|
||||
'id: minimal_json',
|
||||
'id: validation_receipt',
|
||||
'id: expected',
|
||||
'id: actual',
|
||||
'id: environment',
|
||||
'id: sensitive_data',
|
||||
]) {
|
||||
assert.match(template, new RegExp(field), field);
|
||||
}
|
||||
assert.ok(
|
||||
template.indexOf('id: validation_receipt') < template.indexOf('id: screenshot'),
|
||||
'deterministic evidence should be requested before optional visual evidence',
|
||||
);
|
||||
});
|
||||
|
||||
test('contributor and pull-request guides keep proof changes reproducible and stability-first', () => {
|
||||
const contributing = read('CONTRIBUTING.md');
|
||||
const pullRequest = read('.github/PULL_REQUEST_TEMPLATE.md');
|
||||
|
||||
for (const required of [
|
||||
'.github/ISSUE_TEMPLATE/showcase.yml',
|
||||
'.github/ISSUE_TEMPLATE/bug-report.yml',
|
||||
'npm test',
|
||||
'node scripts/build-gallery.mjs docs',
|
||||
'Do not include secrets',
|
||||
'Agent-first',
|
||||
'diagnostics[]',
|
||||
'Start from the latest `main`',
|
||||
'tracked-only, symlink-safe',
|
||||
'is **skipped**, not passed',
|
||||
]) {
|
||||
assert.match(contributing, new RegExp(required.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), required);
|
||||
}
|
||||
assert.match(
|
||||
contributing,
|
||||
/(?:^|\n)scripts\/build-zip\.sh \/tmp\/archify-contrib\.zip(?:\n|$)/,
|
||||
'the archive builder must be documented as an executable shell script',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
contributing,
|
||||
/\bnode\s+scripts\/build-zip\.sh\b/,
|
||||
'the shell archive builder must not be documented as a Node.js command',
|
||||
);
|
||||
for (const required of [
|
||||
'Stability impact',
|
||||
'Tests run',
|
||||
'Generated artifacts',
|
||||
'Visual evidence',
|
||||
'No unrelated changes',
|
||||
]) {
|
||||
assert.match(pullRequest, new RegExp(required), required);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(skillRoot, '..');
|
||||
const cursorCommand = 'npx -y skills add tt-a1i/archify --skill archify --agent cursor --global --copy --yes';
|
||||
|
||||
test('Cursor onboarding stays explicit, bilingual, and backed by the same Skill', () => {
|
||||
const english = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8');
|
||||
const englishMirror = fs.readFileSync(path.join(repoRoot, 'README_EN.md'), 'utf8');
|
||||
const chinese = fs.readFileSync(path.join(repoRoot, 'README_ZH.md'), 'utf8');
|
||||
const start = fs.readFileSync(path.join(repoRoot, 'docs', 'start.html'), 'utf8');
|
||||
const landing = fs.readFileSync(path.join(repoRoot, 'docs', 'index.html'), 'utf8');
|
||||
|
||||
assert.equal(english, englishMirror, 'English README mirrors must stay synchronized');
|
||||
assert.match(english, /Cursor, Claude Code, Codex CLI, and OpenCode/);
|
||||
assert.match(chinese, /Cursor、Claude Code、Codex CLI 和 OpenCode/);
|
||||
for (const surface of [english, chinese, landing]) assert.ok(surface.includes(cursorCommand));
|
||||
for (const surface of [english, chinese, start, landing]) {
|
||||
assert.doesNotMatch(surface, /skills use[^\n<]*--agent cursor/);
|
||||
assert.doesNotMatch(surface, /~\/\.cursor\/skills\/archify/);
|
||||
assert.doesNotMatch(surface, /all Cursor models|every Cursor model/i);
|
||||
}
|
||||
|
||||
assert.match(start, /data-agent="cursor">Cursor<\/button>/);
|
||||
assert.match(start, /data-agent="codex">Codex<\/button>/);
|
||||
assert.match(start, /data-agent="claude-code">Claude Code<\/button>/);
|
||||
assert.match(start, /data-agent="opencode">OpenCode<\/button>/);
|
||||
assert.match(start, /KNOWN_AGENTS\.has\(requestedAgent\)/);
|
||||
assert.match(start, /same Skill/);
|
||||
assert.match(start, /同一份 Skill/);
|
||||
assert.doesNotMatch(start, /vendor-specific (?:renderer|schema|skill)/i);
|
||||
});
|
||||
|
||||
test('the zero-dependency archive works from the canonical Cursor-visible agent path', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-cursor-package-'));
|
||||
const agentSkills = path.join(tmp, '.agents', 'skills');
|
||||
try {
|
||||
fs.mkdirSync(agentSkills, { recursive: true });
|
||||
execFileSync('unzip', ['-q', path.join(repoRoot, 'archify.zip'), '-d', agentSkills]);
|
||||
const installed = path.join(agentSkills, 'archify');
|
||||
const cli = path.join(installed, 'bin', 'archify.mjs');
|
||||
const doctor = execFileSync(process.execPath, [cli, 'doctor'], { encoding: 'utf8' });
|
||||
assert.match(doctor, /Archify is ready\./);
|
||||
|
||||
const fixtures = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
for (const [type, fixture] of Object.entries(fixtures)) {
|
||||
const output = execFileSync(process.execPath, [
|
||||
cli,
|
||||
'validate',
|
||||
type,
|
||||
path.join(installed, 'examples', fixture),
|
||||
'--json',
|
||||
], { encoding: 'utf8' });
|
||||
const receipt = JSON.parse(output);
|
||||
assert.equal(receipt.ok, true, `${type}: installed package validation failed`);
|
||||
assert.equal(receipt.type, type);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
// Installation contract: the shipped skill performs full JSON Schema
|
||||
// validation without node_modules. AJV is a build-time dependency only; its
|
||||
// standalone validators are committed and included in the distribution.
|
||||
//
|
||||
// A malformed-but-JSON-legal document must EXIT
|
||||
// NON-ZERO with a friendly message — never crash (TypeError / is not a
|
||||
// function) and never write NaN/undefined into the HTML. A random VALID
|
||||
// perturbation of an example must still render (exit 0, no NaN).
|
||||
//
|
||||
// node --test test/*.test.mjs
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-degraded-'));
|
||||
|
||||
const EXAMPLES = {
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
architecture: 'web-app.architecture.json',
|
||||
};
|
||||
|
||||
const installedRoot = path.join(tmp, 'installed-skill');
|
||||
fs.cpSync(skillRoot, installedRoot, {
|
||||
recursive: true,
|
||||
filter(source) {
|
||||
const rel = path.relative(skillRoot, source);
|
||||
return rel !== 'node_modules' && !rel.startsWith(`node_modules${path.sep}`)
|
||||
&& rel !== 'test' && !rel.startsWith(`test${path.sep}`)
|
||||
// The validator freshness test creates and removes this fixture inside
|
||||
// skillRoot while the test runner executes files concurrently. Exclude
|
||||
// it from the installed-skill copy to avoid a copy/remove race.
|
||||
&& !rel.startsWith('.validator-check-');
|
||||
},
|
||||
});
|
||||
|
||||
function render(mode, doc) {
|
||||
const input = path.join(tmp, `in-${Math.random().toString(36).slice(2)}.json`);
|
||||
const out = path.join(tmp, 'out.html');
|
||||
fs.writeFileSync(input, JSON.stringify(doc));
|
||||
if (fs.existsSync(out)) fs.rmSync(out);
|
||||
let code = 0;
|
||||
let stderr = '';
|
||||
try {
|
||||
execFileSync('node', [path.join(installedRoot, `renderers/${mode}/render-${mode}.mjs`), input, out],
|
||||
{ stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
} catch (err) {
|
||||
code = err.status ?? 1;
|
||||
stderr = String(err.stderr || '');
|
||||
}
|
||||
const html = fs.existsSync(out) ? fs.readFileSync(out, 'utf8') : '';
|
||||
return { code, stderr, html };
|
||||
}
|
||||
|
||||
function assertFriendlyFailure(mode, doc, label) {
|
||||
const { code, stderr, html } = render(mode, doc);
|
||||
assert.notEqual(code, 0, `${label}: expected non-zero exit`);
|
||||
assert.doesNotMatch(stderr, /TypeError|RangeError|is not a function|Cannot read/,
|
||||
`${label}: crashed instead of reporting friendly error:\n${stderr}`);
|
||||
assert.doesNotMatch(html, /NaN|undefined/, `${label}: wrote NaN/undefined into HTML`);
|
||||
}
|
||||
|
||||
// ---- type-wrong-but-JSON-legal documents per mode ----
|
||||
const ARRAY_FIELDS = {
|
||||
workflow: ['lanes', 'phases', 'groups', 'mainPath', 'nodes', 'edges', 'cards'],
|
||||
sequence: ['participants', 'messages', 'segments', 'activations', 'cards'],
|
||||
dataflow: ['stages', 'nodes', 'flows', 'cards'],
|
||||
lifecycle: ['lanes', 'states', 'transitions', 'cards'],
|
||||
architecture: ['components', 'boundaries', 'connections', 'cards'],
|
||||
};
|
||||
|
||||
for (const [mode, fields] of Object.entries(ARRAY_FIELDS)) {
|
||||
for (const field of fields) {
|
||||
test(`${mode}: ${field} as a string fails friendly`, () => {
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES[mode]), 'utf8'));
|
||||
if (!(field in doc)) return; // optional field absent in this example
|
||||
doc[field] = 'oops';
|
||||
assertFriendlyFailure(mode, doc, `${mode}.${field}`);
|
||||
});
|
||||
}
|
||||
test(`${mode}: scalar meta fails friendly`, () => {
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES[mode]), 'utf8'));
|
||||
doc.meta = 42;
|
||||
assertFriendlyFailure(mode, doc, `${mode}.meta`);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- missing-coordinate fields must not yield NaN coordinates ----
|
||||
test('workflow: node missing col never writes NaN', () => {
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES.workflow), 'utf8'));
|
||||
delete doc.nodes[0].col;
|
||||
assertFriendlyFailure('workflow', doc, 'workflow node no col');
|
||||
});
|
||||
test('lifecycle: state missing col never writes NaN', () => {
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES.lifecycle), 'utf8'));
|
||||
delete doc.states[0].col;
|
||||
assertFriendlyFailure('lifecycle', doc, 'lifecycle state no col');
|
||||
});
|
||||
|
||||
// ---- property test: deterministic VALID perturbations always render ----
|
||||
// Seeded PRNG (no Math.random — keeps the test reproducible across runs).
|
||||
function mulberry32(seed) {
|
||||
return function next() {
|
||||
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
test('property: shuffling node/state order still renders (order-independence)', () => {
|
||||
for (const mode of ['workflow', 'dataflow', 'lifecycle']) {
|
||||
const arrKey = mode === 'lifecycle' ? 'states' : 'nodes';
|
||||
for (let seed = 1; seed <= 8; seed += 1) {
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES[mode]), 'utf8'));
|
||||
const rng = mulberry32(seed);
|
||||
// Fisher–Yates with the seeded PRNG.
|
||||
const a = doc[arrKey];
|
||||
for (let i = a.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
const { code, html } = render(mode, doc);
|
||||
assert.equal(code, 0, `${mode} seed ${seed}: valid shuffle should render (exit 0)`);
|
||||
assert.doesNotMatch(html, /NaN|undefined>/, `${mode} seed ${seed}: NaN in output`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('installed skill rejects unknown fields without node_modules', () => {
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES.workflow), 'utf8'));
|
||||
doc.nodes[0].colour = 'cyan';
|
||||
const { code, stderr } = render('workflow', doc);
|
||||
assert.notEqual(code, 0);
|
||||
assert.match(stderr, /workflow schema validation failed/);
|
||||
assert.match(stderr, /\/nodes\/0 \(id\/label: "user"\) must NOT have additional properties/);
|
||||
assert.match(stderr, /"additionalProperty":"colour"/);
|
||||
assert.doesNotMatch(stderr, /ajv is not installed|skipping JSON-schema validation/);
|
||||
});
|
||||
|
||||
for (const mode of Object.keys(EXAMPLES)) {
|
||||
test(`installed skill retains full ${mode} schema without node_modules`, () => {
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES[mode]), 'utf8'));
|
||||
doc.unknownField = true;
|
||||
const { code, stderr } = render(mode, doc);
|
||||
assert.notEqual(code, 0);
|
||||
assert.match(stderr, new RegExp(`${mode} schema validation failed`));
|
||||
assert.match(stderr, /"additionalProperty":"unknownField"/);
|
||||
});
|
||||
}
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skill = readFileSync(path.join(here, '..', 'SKILL.md'), 'utf8');
|
||||
const delivery = readFileSync(path.join(here, '..', 'references', 'delivery-contract.md'), 'utf8');
|
||||
|
||||
test('skill requires a bounded and truthful perceptual delivery receipt', () => {
|
||||
assert.match(delivery, /visual_review: passed/);
|
||||
assert.match(delivery, /visual_review: skipped \(image reader unavailable\)/);
|
||||
assert.match(delivery, /correction_rounds: [0-2]/);
|
||||
assert.match(delivery, /maximum of two focused correction rounds/i);
|
||||
assert.match(delivery, /never report `visual_review: passed` without inspecting/i);
|
||||
});
|
||||
|
||||
test('skill uses atomic verified delivery for the final artifact', () => {
|
||||
assert.match(delivery, /archify\.mjs deliver <type>/);
|
||||
assert.match(delivery, /same-directory candidate/i);
|
||||
assert.match(delivery, /only replaces the target after.*artifact checks pass/i);
|
||||
assert.match(delivery, /never claim that the deterministic receipt includes visual review/i);
|
||||
});
|
||||
|
||||
test('skill keeps optional opening behind the verified commit and outside automation', () => {
|
||||
assert.match(delivery, /Add `--open` only when the user wants an immediate local preview/);
|
||||
assert.match(delivery, /runs after that atomic commit/);
|
||||
assert.match(delivery, /Keep it off for CI, unattended agents, and non-interactive environments/);
|
||||
assert.match(delivery, /never invokes an opener/);
|
||||
assert.match(delivery, /status proves only whether the local opener invocation succeeded/);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { findChrome, runVisualCheck } from '../bin/visual-check.mjs';
|
||||
import { DESKTOP_READABILITY_VIEWPORT, MIN_PROJECTED_NODE_TEXT_PX } from '../renderers/shared/desktop-readability.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const chromePath = process.env.ARCHIFY_CHROME ? findChrome() : null;
|
||||
|
||||
test('production showcase is readable in the real 1440 by 900 adaptive reader', {
|
||||
skip: chromePath ? false : 'Set ARCHIFY_CHROME to run the real browser regression.',
|
||||
}, async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-desktop-reader-'));
|
||||
const artifact = path.join(tmp, 'production-deployment.html');
|
||||
try {
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, 'bin', 'archify.mjs'),
|
||||
'render',
|
||||
'architecture',
|
||||
path.join(skillRoot, 'examples', 'production-deployment.architecture.json'),
|
||||
artifact,
|
||||
'--quality',
|
||||
'showcase',
|
||||
], { cwd: skillRoot, encoding: 'utf8' });
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
const result = await runVisualCheck({ artifactPath: artifact, chromePath });
|
||||
assert.equal(result.exitCode, 0, `attempt ${attempt}: ${JSON.stringify(result.receipt, null, 2)}`);
|
||||
assert.equal(result.receipt.readability.status, 'pass', `attempt ${attempt}: ${JSON.stringify(result.receipt, null, 2)}`);
|
||||
const desktop = result.receipt.readability.viewports.find(({ width, height }) => (
|
||||
width === DESKTOP_READABILITY_VIEWPORT.width && height === DESKTOP_READABILITY_VIEWPORT.height
|
||||
));
|
||||
const darkDesktop = result.receipt.captures.screenshots.find(({ width, height, theme }) => (
|
||||
width === DESKTOP_READABILITY_VIEWPORT.width
|
||||
&& height === DESKTOP_READABILITY_VIEWPORT.height
|
||||
&& theme === 'dark'
|
||||
));
|
||||
for (const observation of [desktop, darkDesktop]) {
|
||||
assert.ok(observation);
|
||||
assert.equal(observation.readerWidth, 960);
|
||||
assert.equal(observation.diagramWidth, 930);
|
||||
assert.ok(observation.minimumProjectedNodeTextPx >= MIN_PROJECTED_NODE_TEXT_PX);
|
||||
assert.equal(observation.minimumProjectedNodeTextDetail, 'boundary');
|
||||
assert.equal(observation.minimumProjectedNodeText, 'AWS eu-west-1 / disaster recovery');
|
||||
assert.equal(observation.readabilityOk, true);
|
||||
assert.equal(observation.scrollHeight, DESKTOP_READABILITY_VIEWPORT.height);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-diagram-guide-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all typed renderers inherit one viewer-only Diagram Guide', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /id="diagram-guide" hidden role="dialog" aria-modal="false" aria-labelledby="diagram-guide-title"/, mode);
|
||||
assert.match(html, /id="btn-diagram-guide"[^>]+aria-label="Open diagram guide"[^>]+aria-haspopup="dialog"[^>]+aria-expanded="false"/, mode);
|
||||
assert.match(html, /Archify\.guide = \(function \(\)/, mode);
|
||||
assert.match(html, /Diagram Guide — a factual command deck over existing interactions/, mode);
|
||||
assert.doesNotMatch(canonicalSvg(html), /diagram-guide|Archify\.guide|Explore this system/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('Diagram Guide reports compiled semantic facts and honest story availability', () => {
|
||||
const html = render('workflow', CASES.workflow);
|
||||
assert.match(html, /svg\.querySelectorAll\('\[data-node-id\]'\)\.length/);
|
||||
assert.match(html, /svg\.querySelectorAll\('\[data-edge-from\]\[data-edge-to\]'\)/);
|
||||
assert.match(html, /edge\.getAttribute\('data-edge-key'\)/);
|
||||
assert.match(html, /return Archify\.guidedViews && Number\(Archify\.guidedViews\.count\) \|\| 0/);
|
||||
assert.match(html, /storyBtn\.disabled = views === 0/);
|
||||
assert.match(html, /viewerCount\('viewer\.guide\.fact\.view', views\)/);
|
||||
assert.match(html, /viewerText\('viewer\.guide\.story\.unavailable'\)/);
|
||||
});
|
||||
|
||||
test('Diagram Guide delegates its task rows to existing production interactions', () => {
|
||||
const html = render('architecture', CASES.architecture);
|
||||
assert.match(html, /if \(action === 'find'\) return Archify\.finder\.open\(\)/);
|
||||
assert.match(html, /if \(action === 'route'\) return Archify\.routeProbe\.begin\(\{ focusNode: true \}\)/);
|
||||
assert.match(html, /if \(action === 'map'\) return Archify\.radar\.open\(\)/);
|
||||
assert.match(html, /if \(action === 'story'\) return Archify\.guidedViews\.play\(\)/);
|
||||
assert.match(html, /if \(action === 'present'\) return Archify\.presentation\.enter\(\)/);
|
||||
assert.match(html, /if \(action === 'export'\) return Archify\.exportMenu\.open\(\)/);
|
||||
assert.match(html, /if \(action === 'theme'\) return Archify\.theme\.toggle\(\)/);
|
||||
assert.match(html, /if \(action === 'reset'\) return Archify\.view\.reset\(\)/);
|
||||
assert.match(html, /Archify\.guidedViews\.pause\(\)/);
|
||||
assert.match(html, /Archify\.finder\.close\(\{ restoreFocus: false \}\)/);
|
||||
assert.match(html, /Archify\.radar\.close\(\{ restoreFocus: false \}\)/);
|
||||
assert.match(html, /event\.stopPropagation\(\);[\s\S]+execute\(button\.getAttribute\('data-guide-action'\)\)/);
|
||||
});
|
||||
|
||||
test('Diagram Guide is keyboard-first, mobile-contained, motion-safe, and embed-clean', () => {
|
||||
const html = render('sequence', CASES.sequence);
|
||||
assert.match(html, /e\.key === '\?'/);
|
||||
assert.match(html, /Archify\.guide\.toggle\(\)/);
|
||||
assert.match(html, /event\.key === 'ArrowRight'/);
|
||||
assert.match(html, /event\.key === 'ArrowDown'/);
|
||||
assert.match(html, /event\.key === 'Home'/);
|
||||
assert.match(html, /event\.key === 'End'/);
|
||||
assert.match(html, /event\.key === 'Escape' \|\| event\.key === '\?'/);
|
||||
assert.match(html, /html\.setAttribute\('data-guide-open', 'true'\)/);
|
||||
assert.match(html, /html\.getAttribute\('data-guide-open'\) === 'true'/);
|
||||
assert.match(html, /html\[data-embed="true"\] \.diagram-guide/);
|
||||
assert.match(html, /data-wide-diagram="true"\] \.diagram-guide/);
|
||||
assert.match(html, /\.route-probe\[data-guide-open="true"\]/);
|
||||
assert.match(html, /@media \(prefers-reduced-motion: reduce\)[\s\S]+\.diagram-guide/);
|
||||
assert.match(html, /class="diagram-guide no-print"/);
|
||||
assert.doesNotMatch(canonicalSvg(html), /data-guide-open|diagram-guide/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,209 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
deploymentOwnershipDiagnostics,
|
||||
validateEngineeringProfile,
|
||||
} from '../renderers/shared/engineering-profiles.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const cli = path.join(skillRoot, 'bin', 'archify.mjs');
|
||||
const examplePath = path.join(skillRoot, 'examples', 'production-deployment.architecture.json');
|
||||
const example = JSON.parse(fs.readFileSync(examplePath, 'utf8'));
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function validateJson(input, output) {
|
||||
return spawnSync(process.execPath, [cli, 'validate', 'architecture', input, '--json'], {
|
||||
cwd: path.dirname(output),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
test('deployment ownership profile passes the checked production example and stays opt-in', () => {
|
||||
assert.equal(example.meta.engineering_profile, 'deployment-ownership');
|
||||
assert.deepEqual(deploymentOwnershipDiagnostics(example), []);
|
||||
assert.doesNotThrow(() => validateEngineeringProfile('architecture', example));
|
||||
|
||||
const ordinary = clone(example);
|
||||
delete ordinary.meta.engineering_profile;
|
||||
ordinary.boundaries = [];
|
||||
ordinary.components.forEach((component) => { delete component.tag; });
|
||||
assert.doesNotThrow(() => validateEngineeringProfile('architecture', ordinary));
|
||||
});
|
||||
|
||||
test('deployment ownership profile reports exact owners, scopes, state, and crossing mechanisms', () => {
|
||||
const candidate = clone(example);
|
||||
delete candidate.components.find((component) => component.id === 'edge').tag;
|
||||
candidate.boundaries.find((boundary) => boundary.label.includes('us-east-1')).wraps =
|
||||
candidate.boundaries.find((boundary) => boundary.label.includes('us-east-1')).wraps.filter((id) => id !== 'edge');
|
||||
candidate.boundaries.find((boundary) => boundary.label === 'private application network').wraps =
|
||||
candidate.boundaries.find((boundary) => boundary.label === 'private application network').wraps.filter((id) => id !== 'redis');
|
||||
const crossing = candidate.connections.find((connection) => connection.from === 'gateway' && connection.to === 'api_a');
|
||||
crossing.label = '';
|
||||
|
||||
const diagnostics = deploymentOwnershipDiagnostics(candidate);
|
||||
const codes = new Set(diagnostics.map((entry) => entry.code));
|
||||
assert.ok(codes.has('engineering/deployment-owner-missing'));
|
||||
assert.ok(codes.has('engineering/deployment-region-scope'));
|
||||
assert.ok(codes.has('engineering/deployment-private-state'));
|
||||
assert.ok(codes.has('engineering/deployment-crossing-mechanism'));
|
||||
|
||||
const boundaryDiagnostic = diagnostics.find((entry) => entry.code === 'engineering/deployment-crossing-mechanism');
|
||||
assert.equal(boundaryDiagnostic.subject.collection, 'connections');
|
||||
assert.equal(boundaryDiagnostic.evidence.from, 'gateway');
|
||||
assert.equal(boundaryDiagnostic.evidence.to, 'api_a');
|
||||
assert.ok(boundaryDiagnostic.evidence.crossedBoundaries.some((boundary) => boundary.kind === 'security-group'));
|
||||
assert.deepEqual(boundaryDiagnostic.supportedFixes, [
|
||||
`set /connections/${boundaryDiagnostic.subject.index}/label to the real cross-boundary mechanism`,
|
||||
]);
|
||||
});
|
||||
|
||||
test('deployment ownership profile requires both region and private boundary kinds', () => {
|
||||
const candidate = clone(example);
|
||||
candidate.boundaries = candidate.boundaries.filter((boundary) => boundary.kind === 'region');
|
||||
const diagnostics = deploymentOwnershipDiagnostics(candidate);
|
||||
assert.ok(diagnostics.some((entry) => entry.code === 'engineering/deployment-boundary-kind'
|
||||
&& entry.evidence.requiredKind === 'security-group'));
|
||||
});
|
||||
|
||||
test('deployment ownership profile rejects ambiguous regions and cross-region private groups', () => {
|
||||
const candidate = clone(example);
|
||||
const secondRegion = candidate.boundaries.find((boundary) => boundary.label.includes('eu-west-1'));
|
||||
secondRegion.wraps.push('api_a');
|
||||
const diagnostics = deploymentOwnershipDiagnostics(candidate);
|
||||
assert.ok(diagnostics.some((entry) => entry.code === 'engineering/deployment-region-ambiguous'
|
||||
&& entry.subject.id === 'api_a'));
|
||||
assert.ok(diagnostics.some((entry) => entry.code === 'engineering/deployment-private-region-consistency'
|
||||
&& entry.subject.collection === 'boundaries'));
|
||||
});
|
||||
|
||||
test('deployment ownership crossing math follows authored membership instead of geometry or labels', () => {
|
||||
const cases = [
|
||||
['outside to region', 'clients', 'edge'],
|
||||
['region to region', 'postgres', 'replica'],
|
||||
['public to private', 'gateway', 'api_a'],
|
||||
['private to public', 'worker', 'audit'],
|
||||
];
|
||||
for (const [name, from, to] of cases) {
|
||||
const candidate = clone(example);
|
||||
candidate.connections.forEach((connection) => {
|
||||
connection.label ||= 'same-scope relation';
|
||||
});
|
||||
const connection = candidate.connections.find((entry) => entry.from === from && entry.to === to);
|
||||
connection.label = '';
|
||||
const crossings = deploymentOwnershipDiagnostics(candidate)
|
||||
.filter((diagnostic) => diagnostic.code === 'engineering/deployment-crossing-mechanism');
|
||||
assert.equal(crossings.length, 1, name);
|
||||
assert.equal(crossings[0].evidence.from, from, name);
|
||||
assert.equal(crossings[0].evidence.to, to, name);
|
||||
}
|
||||
|
||||
const sameScope = clone(example);
|
||||
sameScope.connections.forEach((connection) => {
|
||||
connection.label ||= 'same-scope relation';
|
||||
});
|
||||
sameScope.connections.find((connection) => connection.from === 'api_a' && connection.to === 'redis').label = '';
|
||||
sameScope.connections.push({ from: 'api_a', to: 'api_a', label: '' });
|
||||
assert.ok(!deploymentOwnershipDiagnostics(sameScope)
|
||||
.some((diagnostic) => diagnostic.code === 'engineering/deployment-crossing-mechanism'));
|
||||
});
|
||||
|
||||
test('other diagram modes reject the architecture-only engineering profile', () => {
|
||||
const fixtures = [
|
||||
['workflow', 'agent-tool-call.workflow.json'],
|
||||
['sequence', 'cache-miss-request.sequence.json'],
|
||||
['dataflow', 'product-analytics.dataflow.json'],
|
||||
['lifecycle', 'agent-run.lifecycle.json'],
|
||||
];
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-engineering-schema-'));
|
||||
try {
|
||||
for (const [mode, fixture] of fixtures) {
|
||||
const candidate = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', fixture), 'utf8'));
|
||||
candidate.meta.engineering_profile = 'deployment-ownership';
|
||||
const input = path.join(tmp, `${mode}.json`);
|
||||
fs.writeFileSync(input, JSON.stringify(candidate));
|
||||
const result = spawnSync(process.execPath, [cli, 'validate', mode, input, '--json'], {
|
||||
cwd: tmp,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.notEqual(result.status, 0, mode);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.ok(receipt.diagnostics.some((diagnostic) => diagnostic.code === 'schema/additionalProperties'), mode);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('validate and deliver expose one truthful engineering-profile receipt', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-engineering-profile-'));
|
||||
try {
|
||||
const invalidPath = path.join(tmp, 'invalid.architecture.json');
|
||||
const invalid = clone(example);
|
||||
const crossing = invalid.connections.find((connection) => connection.from === 'gateway' && connection.to === 'api_a');
|
||||
crossing.label = '';
|
||||
fs.writeFileSync(invalidPath, JSON.stringify(invalid, null, 2));
|
||||
|
||||
const preservedOutput = path.join(tmp, 'preserved.html');
|
||||
const preservedBytes = Buffer.from('last known good deployment');
|
||||
fs.writeFileSync(preservedOutput, preservedBytes);
|
||||
const failedDelivery = spawnSync(process.execPath, [
|
||||
cli, 'deliver', 'architecture', invalidPath, preservedOutput, '--json',
|
||||
], { cwd: tmp, encoding: 'utf8' });
|
||||
assert.notEqual(failedDelivery.status, 0);
|
||||
assert.equal(fs.readFileSync(preservedOutput).equals(preservedBytes), true);
|
||||
|
||||
const failed = validateJson(invalidPath, path.join(tmp, 'unused.html'));
|
||||
assert.notEqual(failed.status, 0);
|
||||
assert.equal(failed.stderr, '');
|
||||
const failure = JSON.parse(failed.stdout);
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.stage, 'render');
|
||||
assert.ok(failure.diagnostics.some((entry) => entry.code === 'engineering/deployment-crossing-mechanism'));
|
||||
|
||||
const validated = spawnSync(process.execPath, [cli, 'validate', 'architecture', examplePath, '--json'], {
|
||||
cwd: tmp,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(validated.status, 0, validated.stderr);
|
||||
assert.equal(JSON.parse(validated.stdout).engineeringProfile, 'deployment-ownership');
|
||||
|
||||
const output = path.join(tmp, 'deployment.html');
|
||||
const delivered = spawnSync(process.execPath, [cli, 'deliver', 'architecture', examplePath, output, '--json'], {
|
||||
cwd: tmp,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(delivered.status, 0, delivered.stderr);
|
||||
const receipt = JSON.parse(delivered.stdout);
|
||||
assert.equal(receipt.validation.engineeringProfile, 'deployment-ownership');
|
||||
assert.match(fs.readFileSync(output, 'utf8'), /data-engineering-profile="deployment-ownership"/);
|
||||
|
||||
const secondOutput = path.join(tmp, 'deployment-second.html');
|
||||
const repeated = spawnSync(process.execPath, [
|
||||
cli, 'deliver', 'architecture', examplePath, secondOutput, '--json',
|
||||
], { cwd: tmp, encoding: 'utf8' });
|
||||
assert.equal(repeated.status, 0, repeated.stderr);
|
||||
const digest = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
assert.equal(digest(output), digest(secondOutput));
|
||||
|
||||
const ordinaryInput = path.join(skillRoot, 'examples', 'web-app.architecture.json');
|
||||
const ordinaryOutput = path.join(tmp, 'ordinary.html');
|
||||
const ordinary = spawnSync(process.execPath, [
|
||||
cli, 'render', 'architecture', ordinaryInput, ordinaryOutput,
|
||||
], { cwd: tmp, encoding: 'utf8' });
|
||||
assert.equal(ordinary.status, 0, ordinary.stderr);
|
||||
assert.doesNotMatch(fs.readFileSync(ordinaryOutput, 'utf8'), /data-engineering-profile=/);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-finder-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function svg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all typed renderers ship the same geometry-neutral node finder', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /id="btn-node-finder"[^>]+aria-label="Find a node"[^>]+aria-haspopup="dialog"/, mode);
|
||||
assert.match(html, /id="node-finder" hidden role="dialog" aria-modal="false"/, mode);
|
||||
assert.match(html, /id="node-finder-input" type="search"/, mode);
|
||||
assert.match(html, /Archify\.finder = \(function \(\)/, mode);
|
||||
assert.match(html, /svg\.querySelectorAll\('\[data-node-id\]'\)/, mode);
|
||||
assert.doesNotMatch(svg(html), /node-finder|Archify\.finder|Find a node/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('finder searches semantic ids and labels, then delegates to focus and reveal', () => {
|
||||
const html = render('architecture', CASES.architecture);
|
||||
assert.match(html, /search: \(id \+ ' ' \+ label \+ ' ' \+ type \+ ' ' \+ sublabel \+ ' ' \+ context \+ ' ' \+ tag \+ ' ' \+ sourceSearch \+ ' ' \+ text\)\.toLowerCase\(\)/);
|
||||
assert.match(html, /item\.search\.indexOf\(query\) !== -1/);
|
||||
assert.match(html, /Archify\.guidedViews\.showAll\(\{ clearFocus: false, updateUrl: false \}\)/);
|
||||
assert.match(html, /Archify\.view\.reset\(\{ automatic: true \}\)/);
|
||||
assert.match(html, /Archify\.focus\.set\(id, \{ toggle: false \}\)/);
|
||||
assert.match(html, /Archify\.view\.reveal\(\[id\], \{ includeNeighbors: true, reason: 'finder' \}\)/);
|
||||
assert.match(html, /item\.node\.focus\(\{ preventScroll: true \}\)/);
|
||||
assert.match(html, /var key = from \+ '\\u0000' \+ to/);
|
||||
});
|
||||
|
||||
test('finder presents one focused search control and a structured result list', () => {
|
||||
const html = render('architecture', CASES.architecture);
|
||||
assert.match(html, /id="node-finder-input"[^>]+aria-label="Search diagram nodes"/);
|
||||
assert.match(html, /\.node-finder-search:focus-within\s*\{/);
|
||||
assert.match(html, /\.node-finder-input:focus-visible\s*\{\s*outline:\s*none;/);
|
||||
assert.match(html, /\.node-finder\s*\{[\s\S]*?display:\s*flex;[\s\S]*?max-height:\s*calc\(100% - 2rem\);/);
|
||||
assert.match(html, /\.node-finder-results\s*\{[\s\S]*?flex:\s*1 1 auto;[\s\S]*?min-height:\s*0;/);
|
||||
assert.match(html, /\.node-finder-result:not\(:last-child\)\s*\{/);
|
||||
assert.match(html, /context\.kind === 'focus'\s*\? viewerCount\('viewer\.finder\.link', item\.links\)/);
|
||||
assert.match(html, /\[viewerKindLabel\(item\.type\), item\.id, item\.sublabel, item\.tag\]/);
|
||||
assert.doesNotMatch(html, /\[item\.type, item\.context, item\.sublabel, item\.tag, item\.id\]/);
|
||||
assert.match(html, /viewerText\('viewer\.finder\.status\.filtered'/);
|
||||
});
|
||||
|
||||
test('finder becomes a contextual Route Probe endpoint picker without changing semantic focus', () => {
|
||||
const html = render('workflow', CASES.workflow);
|
||||
assert.match(html, /function resolveContext\(options\)/);
|
||||
assert.match(html, /Archify\.routeProbe\.finderContext\(\)/);
|
||||
assert.match(html, /context\.allowedIds\.indexOf\(item\.id\) !== -1/);
|
||||
assert.match(html, /context\.kind === 'route-source' \|\| context\.kind === 'route-target'/);
|
||||
assert.match(html, /Archify\.routeProbe\.choose\(id\)/);
|
||||
assert.match(html, /reason: 'route-pick'/);
|
||||
assert.match(html, /data-context="route-source"/);
|
||||
assert.match(html, /data-context="route-target"/);
|
||||
assert.match(html, /viewerText\('viewer\.finder\.result\.routeTarget'/);
|
||||
assert.match(html, /links: badge/);
|
||||
assert.match(html, /viewerText\('viewer\.finder\.status\.all'/);
|
||||
});
|
||||
|
||||
test('finder is keyboard accessible, mobile-pinned, and subordinate to embed mode', () => {
|
||||
const html = render('workflow', CASES.workflow);
|
||||
assert.match(html, /e\.key === '\/'/);
|
||||
assert.match(html, /Archify\.finder\.open\(\)/);
|
||||
assert.match(html, /event\.key === 'ArrowDown'/);
|
||||
assert.match(html, /event\.key === 'ArrowUp'/);
|
||||
assert.match(html, /event\.key === 'Escape'/);
|
||||
assert.match(html, /event\.stopPropagation\(\)/);
|
||||
assert.match(html, /Archify\.exportMenu\.isOpen\(\)\) Archify\.exportMenu\.close\(false\)/);
|
||||
assert.match(html, /data-wide-diagram="true"\] \.node-finder/);
|
||||
assert.match(html, /html\[data-embed="true"\] \.node-finder/);
|
||||
assert.match(html, /html\.getAttribute\('data-embed'\) === 'true'/);
|
||||
assert.match(html, /data-node-finder-trigger/);
|
||||
assert.match(html, /!event\.target\.closest\('\[data-node-finder-trigger\]'\)/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user