diff --git a/external_plugins/archify-panel/.gitignore b/external_plugins/archify-panel/.gitignore new file mode 100644 index 00000000..0ff09cb7 --- /dev/null +++ b/external_plugins/archify-panel/.gitignore @@ -0,0 +1,2 @@ +dist/ +vendor/ diff --git a/external_plugins/archify-panel/.zcode-plugin/plugin.json b/external_plugins/archify-panel/.zcode-plugin/plugin.json new file mode 100644 index 00000000..ac617d91 --- /dev/null +++ b/external_plugins/archify-panel/.zcode-plugin/plugin.json @@ -0,0 +1,41 @@ +{ + "name": "archify-panel", + "version": "0.2.0", + "description": "在 ZCode 侧栏生成并查看 Archify 架构图、流程图、ERD 等图形,可在面板里导出 PNG、SVG 与分享卡片。", + "description_i18n": { + "en": "Generate and view Archify architecture, workflow, ERD and other diagrams in the ZCode side panel, with PNG, SVG and share-card export.", + "zh-CN": "在 ZCode 侧栏生成并查看 Archify 架构图、流程图、ERD 等图形,可在面板里导出 PNG、SVG 与分享卡片。" + }, + "author": { + "name": "ZouKD", + "url": "https://github.com/ZouKD" + }, + "license": "Apache-2.0", + "skills": "skills", + "mcpServers": { + "archify": { + "type": "stdio", + "command": "node", + "args": ["${ZCODE_PLUGIN_ROOT}/dist/server.mjs"], + "cwd": "${ZCODE_PROJECT_DIR}", + "env": { + "ZCODE_WORKSPACE_ROOT": "${ZCODE_PROJECT_DIR}" + } + } + }, + "ui": { + "surfaces": [ + { + "id": "archify", + "title": { + "zh-CN": "Archify 画板", + "en-US": "Archify" + }, + "icon": "git-branch", + "server": "archify", + "resourceUri": "ui://archify/panel.html", + "availability": "session" + } + ] + } +} diff --git a/external_plugins/archify-panel/NOTICE.md b/external_plugins/archify-panel/NOTICE.md new file mode 100644 index 00000000..b41157dd --- /dev/null +++ b/external_plugins/archify-panel/NOTICE.md @@ -0,0 +1,24 @@ +# NOTICE + +## Archify + +本插件打包了 [Archify](https://github.com/tt-a1i/archify) v3.2.0 的运行时,位于 `vendor/archify/`,按 Apache-2.0 授权。 + +- `vendor/archify/LICENSE`:Archify 的 Apache-2.0 授权全文 +- `vendor/archify/LICENSE-MIT`:Archify 早期的 MIT 授权,仍然有效 +- `vendor/archify/NOTICE`:Archify 原始声明 +- `vendor/archify/THIRD_PARTY_NOTICES.md`:Archify 收录的第三方资产与许可 + +Archify 的名称与标识不表示任何背书或授权关系。 + +## 对 Archify 的改动 + +按 Apache-2.0 第 4(b) 条,在此声明对打包代码的修改: + +- `vendor/archify/renderers/shared/generated-brand-marks.mjs`:删除了 Jenkins、Rust、Vue.js 三个品牌标的矢量路径数据。三者在 Archify 记录的许可分别是 CC-BY-SA-3.0、CC-BY-SA-4.0、CC-BY-NC-SA-4.0,含有相同方式共享或非商业条件,与本插件的再分发方式冲突。其余 104 个品牌标按原样保留。引用这三个品牌会报 `brand/unknown`。 + +除上述改动外,Archify 源码未作修改。 + +## 其它第三方组件 + +- `@modelcontextprotocol/sdk`(MIT)与 `@modelcontextprotocol/ext-apps`(MIT):构建期依赖,打包进 `dist/server.mjs` 与 `dist/ui/panel.html`。 diff --git a/external_plugins/archify-panel/README.md b/external_plugins/archify-panel/README.md new file mode 100644 index 00000000..cca998f4 --- /dev/null +++ b/external_plugins/archify-panel/README.md @@ -0,0 +1,98 @@ +# Archify Panel (archify-panel) + +

+ Archify Panel +

+ +[Archify](https://github.com/tt-a1i/archify), adapted for the ZCode side panel: this plugin bundles Archify's diagram engine (v3.2.0) and brings architecture, workflow, sequence, dataflow, state machine, ERD, class, tree, timeline and waterfall diagrams into the ZCode side panel with full interaction and export. + +> Source repository: [tt-a1i/archify](https://github.com/tt-a1i/archify) — the upstream diagram engine (Apache-2.0). + +> 中文文档:[README_CN.md](README_CN.md) + +## Features + +- Agent-driven rendering: describe a diagram in natural language; the agent renders it with the bundled Archify runtime +- Interactive side panel: results open in the ZCode side panel in full-interaction mode +- Export: PNG, SVG and share cards from the diagram's own export menu, saved into the workspace + +## Install + +Install from the ZCode Plugin Marketplace (**Settings → Plugins → Plugin Marketplace**), search for Archify. + +## Usage + +After installation, just ask the agent to draw, for example: + +- "Draw an architecture diagram of this repository's frontend and backend" +- "Show the login request flow as a sequence diagram" + +The agent renders via the bundled skill `archify-panel`; the result opens in the side panel automatically. + +## Security & side effects + +- **MCP server**: registers a local stdio MCP server `archify` that provides the diagram rendering tools +- **File writes**: rendered artifacts are written to the workspace `.archify-panel/` (exports go to `exports/`) +- **Browser use**: the render pipeline launches the local browser for a measurement step; data stays on the machine +- **Network access**: none. Rendering is fully local; the plugin calls no external services +- **Telemetry**: none + +## Build from source + +Plugin source lives in `ui-plugins/archify-panel/` in this repository; the install directory contents (`dist/` and `vendor/`) are generated by the repository build: + +```bash +pnpm install --frozen-lockfile +pnpm build +``` + +The build produces `dist/server.mjs` and `dist/ui/panel.html`; `vendor/` is copied into the install directory by the stage script. + +Tests (after building): + +```bash +node ui-plugins/archify-panel/test/smoke.mjs /tmp/archify-smoke # server: render and read artifacts +node ui-plugins/archify-panel/test/panel-run.mjs # panel: export bridge end to end +``` + +### Local development (inline loading) + +To develop without the marketplace, load the plugin directory directly via `plugins.dirs` in `~/.zcode/cli/config.json`: + +```json +{ "plugins": { "dirs": ["/absolute/path/to/archify-panel"] } } +``` + +- **Use an absolute path.** The desktop app expands `~/`, CLI tools do not. +- **Each `plugins.dirs` entry is a plugin root** (containing `.zcode-plugin/plugin.json` directly), not a parent directory of several plugins. +- **Do not install this plugin from a marketplace at the same time.** The host deduplicates by `name@marketplace`; `archify-panel@inline` and `archify-panel@` are separate keys and both would load — two MCP processes, duplicate panel registrations and skills. + +## Structure + +```text +.zcode-plugin/plugin.json manifest: MCP server + side panel declaration +skills/archify-panel/SKILL.md skill: how the agent renders and drives the panel +dist/ build output: MCP server and panel frontend +vendor/archify/ bundled Archify runtime (Apache-2.0) +assets/icon.png plugin icon +``` + +Source (`src/`, `test/` and build scripts) lives in `ui-plugins/archify-panel/` in this repository. + +## How it works + +The agent calls `render_diagram`; the server runs Archify's `finalize` pipeline (validation, delivery, static checks, browser measurement) and writes the artifact to the workspace `.archify-panel/`, registering it as the current diagram. The side panel reads the artifact HTML via an MCP resource and embeds it in full-interaction mode inside an inner iframe `srcdoc`; the shell renders no toolbar and keeps no status bar, surfacing only a brief toast on errors and export results. + +Exports go through the diagram's own "Export" menu. Sandboxed iframes block native downloads, so the panel keeps a permanent export bridge: it intercepts the binary export output, saves it to `.archify-panel/exports/` via `save_export`, and asks the host to open a save dialog. + +## Upstream & third-party components + +- Upstream source repository: [tt-a1i/archify](https://github.com/tt-a1i/archify). Bundled runtime is Archify v3.2.0, Apache-2.0 (historical MIT grants for pre-transition contributions remain in effect). See [NOTICE.md](NOTICE.md), including modifications declared under Apache-2.0 §4(b). +- `@modelcontextprotocol/sdk` and `@modelcontextprotocol/ext-apps` (MIT): build-time dependencies, bundled into `dist/`. +- The icon is original artwork generated for this plugin; no Archify trademark is used. + +This plugin is not affiliated with or endorsed by the Archify project. + +## License + +Apache-2.0 diff --git a/external_plugins/archify-panel/README_CN.md b/external_plugins/archify-panel/README_CN.md new file mode 100644 index 00000000..1d60846e --- /dev/null +++ b/external_plugins/archify-panel/README_CN.md @@ -0,0 +1,98 @@ +# Archify 侧栏画板(archify-panel) + +

+ Archify 侧栏画板 +

+ +[Archify](https://github.com/tt-a1i/archify) 的 ZCode 侧栏适配:本插件打包 Archify v3.2.0 图形引擎,把架构图、流程图、时序图、数据流、状态机、ERD、类图、树、时间线、瀑布图带进 ZCode 侧栏面板,支持完整交互与导出。 + +> 源仓库:[tt-a1i/archify](https://github.com/tt-a1i/archify) —— 上游图形引擎(Apache-2.0)。 + +> English documentation: [README.md](README.md) + +## 功能 + +- Agent 出图:用自然语言描述,Agent 调用内置 Archify 运行时完成渲染 +- 侧栏交互:渲染结果直接在 ZCode 侧栏面板中以完整交互模式打开 +- 导出:从图形自带的导出菜单导出 PNG、SVG 与分享卡片,落盘到工作区 + +## 安装 + +在 ZCode 插件市场安装:**设置 → 插件 → 插件市场**,搜索 Archify 安装即可。 + +## 使用 + +安装后直接让 Agent 画图,例如: + +- 「用架构图画出这个仓库的前后端结构」 +- 「画一张登录请求的时序图」 + +Agent 会通过内置技能 `archify-panel` 完成渲染,产物自动在侧栏面板打开。 + +## 安全与副作用 + +- **MCP 服务**:注册本地 stdio MCP 服务 `archify`,提供图形渲染相关工具 +- **文件写入**:渲染产物写入工作区 `.archify-panel/`(导出文件在 `exports/` 子目录) +- **浏览器调用**:渲染管线的测量环节会启动本机浏览器完成尺寸测量,数据不出本机 +- **网络访问**:无。渲染完全在本地进行,不调用外部服务 +- **遥测**:无 + +## 从源码构建 + +插件源码在本仓库 `ui-plugins/archify-panel/`,安装目录内容(`dist/` 与 `vendor/`)由仓库统一构建生成: + +```bash +pnpm install --frozen-lockfile +pnpm build +``` + +构建产出 `dist/server.mjs` 与 `dist/ui/panel.html`,`vendor/` 由 stage 脚本复制进安装目录。 + +本地测试(需先构建): + +```bash +node ui-plugins/archify-panel/test/smoke.mjs /tmp/archify-smoke # 服务端:出图、读产物 +node ui-plugins/archify-panel/test/panel-run.mjs # 面板:模拟宿主下走一遍导出链路 +``` + +### 本地开发(inline 加载) + +不经过市场、直接开发调试时,可在 `~/.zcode/cli/config.json` 用 `plugins.dirs` 直接加载插件目录: + +```json +{ "plugins": { "dirs": ["/绝对路径/archify-panel"] } } +``` + +- **路径必须写绝对路径**。桌面端能展开 `~/`,命令行工具不能。 +- **`plugins.dirs` 的每一项本身就是一个插件根目录**(直接含 `.zcode-plugin/plugin.json`),不是包含多个插件的父目录。 +- **不要同时从市场安装本插件。** 宿主的插件去重键是 `名称@市场`,`archify-panel@inline` 与 `archify-panel@某市场` 是两个不同的键,两份都会被加载 —— MCP 服务会起两个进程,侧栏面板注册两次,技能重名。 + +## 结构 + +```text +.zcode-plugin/plugin.json 插件清单:MCP 服务 + 侧栏面板声明 +skills/archify-panel/SKILL.md Agent 写图与调用面板的说明 +dist/ 构建产物:MCP 服务端与面板前端 +vendor/archify/ 打包的 Archify 运行时(Apache-2.0) +assets/icon.png 插件标识 +``` + +源码(`src/`、`test/` 与构建脚本)位于本仓库 `ui-plugins/archify-panel/`。 + +## 工作原理 + +Agent 调用 `render_diagram`,服务端跑 Archify 的 `finalize`(校验、交付、静态检查、浏览器测量四道关卡),产物落盘到工作区 `.archify-panel/` 并登记为当前图。侧栏面板通过 MCP 资源读取产物 HTML,以完整交互模式塞进内层 iframe 的 `srcdoc`;面板薄壳不渲染任何工具栏,也不保留常驻状态栏,只有出错或导出结果会浮出一小条提示。 + +导出走图形自带的「导出」菜单。沙箱 iframe 里浏览器会拦截原生下载,所以面板常驻一条导出桥接:截获产物导出产生的二进制,经 `save_export` 落到工作区 `.archify-panel/exports/`,并请求宿主弹出另存为。 + +## 上游与第三方组件 + +- 上游源仓库:[tt-a1i/archify](https://github.com/tt-a1i/archify)。打包运行时为 Archify v3.2.0,Apache-2.0(过渡前的历史 MIT 授权仍有效)。详见 [NOTICE.md](NOTICE.md),含按 Apache-2.0 第 4(b) 条声明的修改。 +- `@modelcontextprotocol/sdk` 与 `@modelcontextprotocol/ext-apps`(MIT):构建期依赖,打包进 `dist/`。 +- 图标为本插件原创生成,不使用 Archify 的商标与标识。 + +本插件与 Archify 官方无隶属关系,也未获其背书。 + +## 许可 + +Apache-2.0 diff --git a/external_plugins/archify-panel/assets/icon.png b/external_plugins/archify-panel/assets/icon.png new file mode 100644 index 00000000..2b0e7cc3 Binary files /dev/null and b/external_plugins/archify-panel/assets/icon.png differ diff --git a/external_plugins/archify-panel/skills/archify-panel/SKILL.md b/external_plugins/archify-panel/skills/archify-panel/SKILL.md new file mode 100644 index 00000000..29878c97 --- /dev/null +++ b/external_plugins/archify-panel/skills/archify-panel/SKILL.md @@ -0,0 +1,68 @@ +--- +name: archify-panel +description: "用 Archify 生成架构图、流程图、时序图、数据流、状态机、ERD、类图、树、时间线与瀑布图,产物直接显示在 ZCode 侧栏面板,可在面板里导出 PNG、SVG 与分享卡片。当用户要画图、画架构、画流程、画时序、画状态机、画 ERD、画类图、看代码库结构、看请求时序、看故障时间线时使用。" +license: Apache-2.0 +--- + +# Archify 侧栏画板 + +用 Archify 的 JSON IR 描述一张图,插件把它渲染成单文件 HTML 并显示在 ZCode 侧栏面板。 + +## 何时使用 + +用户要画图、看结构、看时序、做架构梳理时使用本技能。图是本技能唯一的交付物,文字只用来补充图里放不下的结论。 + +## 怎么生成 + +先调用 `get_authoring_assets` 拿到 schema 与用例的绝对路径,读完对应 schema 再调用 MCP 工具 `render_diagram`: + +```text +render_diagram(type, spec, title, quality?) +``` + +- `type`:图的类型,见下面的类型路由表 +- `spec`:完整的 Archify JSON IR(对象) +- `title`:图的标题,同时是导出文件名 +- `quality`:`showcase`(默认,信息密度适合阅读)或 `standard`(密集大图) + +工具返回后会做四道质量关卡(校验、交付、静态检查、浏览器测量)。关卡全过时用户看到的是一张成品图;浏览器关卡被跳过时图照样交付,工具结果里会写明"未做浏览器校验",回复时如实转达。 + +工具失败时按返回的 `diagnostics` 修图再调一次,不要反复提交同一份。 + +## 类型路由 + +写图前先调用 `get_authoring_assets`,它返回 schema、用例与写作参考的绝对路径;读对应 schema 再写字段,用例只学形状不抄内容。 + +| 类型 | 用途 | Schema | 用例 | +|---|---|---|---| +| `architecture` | 组件、服务、云与安全边界、基础设施 | `architecture.schema.json` | `production-deployment.architecture.json` | +| `workflow` | 流程、审批关卡、工具调用、CI/CD | `workflow.schema.json` | `agent-tool-call.workflow.json` | +| `sequence` | 接口调用链、请求生命周期、异步追踪 | `sequence.schema.json` | `cache-miss-request.sequence.json` | +| `dataflow` | 管道、ETL/ELM、血缘、数据治理 | `dataflow.schema.json` | `product-analytics.dataflow.json` | +| `lifecycle` | 状态与状态迁移、重试、等待、终态 | `lifecycle.schema.json` | `deployment-release.lifecycle.json` | +| `erd` | 表与实体、字段、主外键、基数 | `erd.schema.json` | `orders.erd.json` | +| `class` | 类、接口、成员、依赖与继承关系 | `class.schema.json` | `payments.class.json` | +| `tree` | 单父级包含:仓库树、模块树、能力拆解 | `tree.schema.json` | `payment-platform.tree.json` | +| `timeline` | 何时发生:故障复盘、发布史、事件时钟 | `timeline.schema.json` | `payment-incident.timeline.json` | +| `waterfall` | 时间花在哪:一次请求或一次运行的耗时分布 | `waterfall.schema.json` | `checkout-request.waterfall.json` | + +字段细节见 `authoring-contract.md`,整体画法约定见 `authoring-defaults.md`,画真实仓库时再读 `repository-authoring.md`。 + +## 写图的要点 + +- 所有图共用顶层:`schema_version`、`diagram_type`、`meta`、`cards`(可选)。`meta` 里放 `title`、`subtitle`、`locale`、`quality_profile`。 +- 节点与关系用新写的 ID 与措辞,不要复制用例里的原句。 +- 没有节点数目标:写全该写的,不为了凑数删关系,也不为了填满加关系。 +- 优先让渲染器自动布线,只在分支、回路或实测拥挤处指定路径。 +- `meta.quality_profile` 默认 `showcase`;只有确实很密的大图才用 `standard`。 +- ERD 用表优先画法:字段写在 `entities[].attributes` 里并保留 SQL 类型,`key` 可以是数组(连接表写 `["pk","fk"]`),每个 `fk` 字段都要有 `references`;按业务域分组,每个域用 `tag` 标注并排成一块矩形。 +- 需要品牌图标时用 `icon` 或 `brand` 字段。Jenkins、Rust、Vue 三个品牌标已从本插件中剔除(许可限制),用到它们会报 `brand/unknown`,换成中性图标或文字即可。 + +## 用户在面板里能做什么 + +- 面板只承载图形本体,常驻完整交互视图,没有额外的面板工具栏。 +- 导出、主题、视图预设都在图形自带的工具栏里操作:「导出」菜单可以出 PNG、SVG、WebM 录屏与分享卡片,文件落在工作区 `.archify-panel/exports/` 并弹出另存为。 +- 分享卡片需要先在图上选一条路径或一个节点。 +- 想让面板切换显示哪张图,直接在对话里说,Agent 调 `open_diagram`。 + +面板里的图就是交付物,回复时一句话给出结论或提示即可,不要复述图上已有的内容。 diff --git a/marketplace.json b/marketplace.json index c7f09a06..2a521ec6 100644 --- a/marketplace.json +++ b/marketplace.json @@ -1443,6 +1443,30 @@ "mcp", "verification" ] + }, + { + "name": "archify-panel", + "source": "./external_plugins/archify-panel", + "description": "在 ZCode 侧栏生成并查看 Archify 架构图、流程图、ERD 等图形,可在面板里导出 PNG、SVG 与分享卡片。", + "description_i18n": { + "en": "Generate and view Archify architecture, workflow, ERD and other diagrams in the ZCode side panel, with PNG, SVG and share-card export.", + "zh-CN": "在 ZCode 侧栏生成并查看 Archify 架构图、流程图、ERD 等图形,可在面板里导出 PNG、SVG 与分享卡片。" + }, + "version": "0.2.0", + "author": { + "name": "ZouKD", + "url": "https://github.com/ZouKD" + }, + "category": "developer-tools", + "keywords": [ + "diagram", + "architecture", + "workflow", + "sequence", + "erd", + "visualization" + ], + "license": "Apache-2.0" } ] } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14b35f76..82683387 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,24 @@ importers: specifier: 4.6.5 version: 4.6.5 + ui-plugins/archify-panel: + devDependencies: + '@modelcontextprotocol/ext-apps': + specifier: ^2.0.0 + version: 2.0.0(@modelcontextprotocol/client@2.2.0)(@modelcontextprotocol/core@2.2.0)(@modelcontextprotocol/server@2.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.6.5) + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.6.5) + '@types/node': + specifier: ^26.6.6 + version: 26.6.6 + esbuild: + specifier: ^0.28.2 + version: 0.28.2 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + ui-plugins/cadcraft: {} ui-plugins/craft-runtime: {} @@ -193,7 +211,7 @@ importers: version: 0.14.0 '@open-pencil/vue': specifier: 0.14.0 - version: 0.14.0(@open-pencil/core@0.14.0)(canvaskit-wasm@0.40.0)(vue@3.5.42(typescript@6.0.2)) + version: 0.14.0(@open-pencil/core@0.14.0)(canvaskit-wasm@0.40.0)(vue@3.5.42(typescript@7.0.2)) canvaskit-wasm: specifier: 0.40.0 version: 0.40.0 @@ -202,7 +220,7 @@ importers: version: 3.2.1 vue: specifier: 3.5.42 - version: 3.5.42(typescript@6.0.2) + version: 3.5.42(typescript@7.0.2) zod: specifier: ^4.4.3 version: 4.6.5 @@ -426,6 +444,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -438,6 +462,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -450,6 +480,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -462,6 +498,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -474,6 +516,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -486,6 +534,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -498,6 +552,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -510,6 +570,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -522,6 +588,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -534,6 +606,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -546,6 +624,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -558,6 +642,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -570,6 +660,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -582,6 +678,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -594,6 +696,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -606,6 +714,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -618,6 +732,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -630,6 +750,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -642,6 +768,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -654,6 +786,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -666,6 +804,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -678,6 +822,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -690,6 +840,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -702,6 +858,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -714,6 +876,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -726,6 +894,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@excalidraw/excalidraw@0.18.1': resolution: {integrity: sha512-6i5Gt7IDTOH//qa0Z315Ly5iVRhjWpu2whrlQFqkuwrkKUWgRsMk0P5qdE7bpyDpai7jeLeWYkyj1eVAfni1lw==} peerDependencies: @@ -1881,6 +2055,9 @@ packages: '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/node@26.6.6': + resolution: {integrity: sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==} + '@types/pako@2.0.4': resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} @@ -1904,6 +2081,126 @@ packages: '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} @@ -2458,6 +2755,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -2475,10 +2777,6 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - eventsource-parser@3.1.0: - resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} - engines: {node: '>=18.0.0'} - eventsource-parser@3.1.1: resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} engines: {node: '>=18.0.0'} @@ -2714,9 +3012,6 @@ packages: jose@6.2.12: resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} - jose@6.2.2: - resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} - jotai-scope@0.7.2: resolution: {integrity: sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==} peerDependencies: @@ -3460,6 +3755,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} @@ -3469,6 +3769,9 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + undici@8.10.2: resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} engines: {node: '>=22.19.0'} @@ -3902,156 +4205,234 @@ snapshots: '@esbuild/aix-ppc64@0.27.7': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm64@0.27.7': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-arm@0.27.7': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + '@esbuild/android-x64@0.25.12': optional: true '@esbuild/android-x64@0.27.7': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-arm64@0.27.7': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/darwin-x64@0.27.7': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.27.7': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/freebsd-x64@0.27.7': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm64@0.27.7': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-arm@0.27.7': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-ia32@0.27.7': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-loong64@0.27.7': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-mips64el@0.27.7': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-ppc64@0.27.7': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-riscv64@0.27.7': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-s390x@0.27.7': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true '@esbuild/linux-x64@0.27.7': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.27.7': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true '@esbuild/netbsd-x64@0.27.7': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.27.7': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true '@esbuild/openbsd-x64@0.27.7': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true '@esbuild/openharmony-arm64@0.27.7': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/sunos-x64@0.27.7': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-arm64@0.27.7': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-ia32@0.27.7': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true '@esbuild/win32-x64@0.27.7': optional: true + '@esbuild/win32-x64@0.28.2': + optional: true + '@excalidraw/excalidraw@0.18.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@11.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@braintree/sanitize-url': 6.0.2 @@ -4122,11 +4503,11 @@ snapshots: '@floating-ui/utils@0.2.11': {} - '@floating-ui/vue@1.1.11(vue@3.5.42(typescript@6.0.2))': + '@floating-ui/vue@1.1.11(vue@3.5.42(typescript@7.0.2))': dependencies: '@floating-ui/dom': 1.7.6 '@floating-ui/utils': 0.2.11 - vue-demi: 0.14.10(vue@3.5.42(typescript@6.0.2)) + vue-demi: 0.14.10(vue@3.5.42(typescript@7.0.2)) transitivePeerDependencies: - '@vue/composition-api' - vue @@ -4276,6 +4657,17 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + '@modelcontextprotocol/ext-apps@2.0.0(@modelcontextprotocol/client@2.2.0)(@modelcontextprotocol/core@2.2.0)(@modelcontextprotocol/server@2.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.6.5)': + dependencies: + '@modelcontextprotocol/client': 2.2.0 + '@modelcontextprotocol/core': 2.2.0 + '@standard-schema/spec': 1.1.0 + zod: 4.6.5 + optionalDependencies: + '@modelcontextprotocol/server': 2.0.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + '@modelcontextprotocol/sdk@1.29.0(zod@4.6.5)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.12) @@ -4285,11 +4677,11 @@ snapshots: cors: 2.8.6 cross-spawn: 7.0.6 eventsource: 3.0.7 - eventsource-parser: 3.1.0 + eventsource-parser: 3.1.1 express: 5.2.1 express-rate-limit: 8.3.2(express@5.2.1) hono: 4.12.12 - jose: 6.2.2 + jose: 6.2.12 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -4318,10 +4710,10 @@ snapshots: dependencies: nanostores: 1.5.3 - '@nanostores/vue@1.1.0(nanostores@1.5.3)(vue@3.5.42(typescript@6.0.2))': + '@nanostores/vue@1.1.0(nanostores@1.5.3)(vue@3.5.42(typescript@7.0.2))': dependencies: nanostores: 1.5.3 - vue: 3.5.42(typescript@6.0.2) + vue: 3.5.42(typescript@7.0.2) '@napi-rs/canvas-android-arm64@1.0.11': optional: true @@ -4454,19 +4846,19 @@ snapshots: nanoevents: 9.1.0 svgpath: 2.6.0 - '@open-pencil/vue@0.14.0(@open-pencil/core@0.14.0)(canvaskit-wasm@0.40.0)(vue@3.5.42(typescript@6.0.2))': + '@open-pencil/vue@0.14.0(@open-pencil/core@0.14.0)(canvaskit-wasm@0.40.0)(vue@3.5.42(typescript@7.0.2))': dependencies: '@atlaskit/pragmatic-drag-and-drop': 1.8.1 '@atlaskit/pragmatic-drag-and-drop-hitbox': 1.2.0 '@nanostores/i18n': 1.3.3(nanostores@1.5.3) - '@nanostores/vue': 1.1.0(nanostores@1.5.3)(vue@3.5.42(typescript@6.0.2)) + '@nanostores/vue': 1.1.0(nanostores@1.5.3)(vue@3.5.42(typescript@7.0.2)) '@open-pencil/core': 0.14.0 '@open-pencil/scene-graph': 0.14.0 - '@tanstack/vue-table': 8.21.3(vue@3.5.42(typescript@6.0.2)) - '@vueuse/core': 14.4.0(vue@3.5.42(typescript@6.0.2)) + '@tanstack/vue-table': 8.21.3(vue@3.5.42(typescript@7.0.2)) + '@vueuse/core': 14.4.0(vue@3.5.42(typescript@7.0.2)) nanostores: 1.5.3 - reka-ui: 2.10.4(vue@3.5.42(typescript@6.0.2)) - vue: 3.5.42(typescript@6.0.2) + reka-ui: 2.10.4(vue@3.5.42(typescript@7.0.2)) + vue: 3.5.42(typescript@7.0.2) optionalDependencies: canvaskit-wasm: 0.40.0 transitivePeerDependencies: @@ -4968,15 +5360,15 @@ snapshots: '@tanstack/virtual-core@3.17.11': {} - '@tanstack/vue-table@8.21.3(vue@3.5.42(typescript@6.0.2))': + '@tanstack/vue-table@8.21.3(vue@3.5.42(typescript@7.0.2))': dependencies: '@tanstack/table-core': 8.21.3 - vue: 3.5.42(typescript@6.0.2) + vue: 3.5.42(typescript@7.0.2) - '@tanstack/vue-virtual@3.13.39(vue@3.5.42(typescript@6.0.2))': + '@tanstack/vue-virtual@3.13.39(vue@3.5.42(typescript@7.0.2))': dependencies: '@tanstack/virtual-core': 3.17.11 - vue: 3.5.42(typescript@6.0.2) + vue: 3.5.42(typescript@7.0.2) '@tauri-apps/api@2.11.1': {} @@ -5116,6 +5508,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/node@26.6.6': + dependencies: + undici-types: 8.9.0 + '@types/pako@2.0.4': {} '@types/raf@3.4.3': @@ -5137,6 +5533,66 @@ snapshots: '@types/web-bluetooth@0.0.21': {} + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + '@upsetjs/venn.js@2.0.0': optionalDependencies: d3-selection: 3.0.0 @@ -5238,18 +5694,18 @@ snapshots: '@vue/shared@3.5.42': {} - '@vueuse/core@14.4.0(vue@3.5.42(typescript@6.0.2))': + '@vueuse/core@14.4.0(vue@3.5.42(typescript@7.0.2))': dependencies: '@types/web-bluetooth': 0.0.21 '@vueuse/metadata': 14.4.0 - '@vueuse/shared': 14.4.0(vue@3.5.42(typescript@6.0.2)) - vue: 3.5.42(typescript@6.0.2) + '@vueuse/shared': 14.4.0(vue@3.5.42(typescript@7.0.2)) + vue: 3.5.42(typescript@7.0.2) '@vueuse/metadata@14.4.0': {} - '@vueuse/shared@14.4.0(vue@3.5.42(typescript@6.0.2))': + '@vueuse/shared@14.4.0(vue@3.5.42(typescript@7.0.2))': dependencies: - vue: 3.5.42(typescript@6.0.2) + vue: 3.5.42(typescript@7.0.2) '@webgpu/types@0.1.21': {} @@ -5796,6 +6252,35 @@ snapshots: '@esbuild/win32-x64': 0.27.7 optional: true + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: optional: true @@ -5809,13 +6294,11 @@ snapshots: etag@1.8.1: {} - eventsource-parser@3.1.0: {} - eventsource-parser@3.1.1: {} eventsource@3.0.7: dependencies: - eventsource-parser: 3.1.0 + eventsource-parser: 3.1.1 expect-type@1.4.0: {} @@ -6048,8 +6531,6 @@ snapshots: jose@6.2.12: {} - jose@6.2.2: {} - jotai-scope@0.7.2(jotai@2.11.0(@types/react@19.2.14)(react@19.2.7))(react@19.2.7): dependencies: jotai: 2.11.0(@types/react@19.2.14)(react@19.2.7) @@ -6541,19 +7022,19 @@ snapshots: regenerator-runtime@0.13.11: optional: true - reka-ui@2.10.4(vue@3.5.42(typescript@6.0.2)): + reka-ui@2.10.4(vue@3.5.42(typescript@7.0.2)): dependencies: '@floating-ui/dom': 1.7.6 - '@floating-ui/vue': 1.1.11(vue@3.5.42(typescript@6.0.2)) + '@floating-ui/vue': 1.1.11(vue@3.5.42(typescript@7.0.2)) '@internationalized/date': 3.12.4 '@internationalized/number': 3.6.8 - '@tanstack/vue-virtual': 3.13.39(vue@3.5.42(typescript@6.0.2)) - '@vueuse/core': 14.4.0(vue@3.5.42(typescript@6.0.2)) - '@vueuse/shared': 14.4.0(vue@3.5.42(typescript@6.0.2)) + '@tanstack/vue-virtual': 3.13.39(vue@3.5.42(typescript@7.0.2)) + '@vueuse/core': 14.4.0(vue@3.5.42(typescript@7.0.2)) + '@vueuse/shared': 14.4.0(vue@3.5.42(typescript@7.0.2)) aria-hidden: 1.2.6 defu: 6.1.7 ohash: 2.0.11 - vue: 3.5.42(typescript@6.0.2) + vue: 3.5.42(typescript@7.0.2) transitivePeerDependencies: - '@vue/composition-api' @@ -6853,12 +7334,37 @@ snapshots: typescript@6.0.2: {} + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + ufo@1.6.3: {} undici-types@6.21.0: {} undici-types@7.18.2: {} + undici-types@8.9.0: {} + undici@8.10.2: {} unifont@0.7.5: @@ -6964,11 +7470,11 @@ snapshots: vscode-uri@3.1.0: {} - vue-demi@0.14.10(vue@3.5.42(typescript@6.0.2)): + vue-demi@0.14.10(vue@3.5.42(typescript@7.0.2)): dependencies: - vue: 3.5.42(typescript@6.0.2) + vue: 3.5.42(typescript@7.0.2) - vue@3.5.42(typescript@6.0.2): + vue@3.5.42(typescript@7.0.2): dependencies: '@vue/compiler-dom': 3.5.42 '@vue/compiler-sfc': 3.5.42 @@ -6976,7 +7482,7 @@ snapshots: '@vue/server-renderer': 3.5.42 '@vue/shared': 3.5.42 optionalDependencies: - typescript: 6.0.2 + typescript: 7.0.2 w3c-keyname@2.2.8: {} diff --git a/ui-plugins/archify-panel/build.mjs b/ui-plugins/archify-panel/build.mjs new file mode 100644 index 00000000..b20d6caf --- /dev/null +++ b/ui-plugins/archify-panel/build.mjs @@ -0,0 +1,39 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +const root = dirname(fileURLToPath(import.meta.url)); + +await mkdir(join(root, 'dist', 'ui'), { recursive: true }); + +await build({ + entryPoints: [join(root, 'src', 'server', 'main.ts')], + outfile: join(root, 'dist', 'server.mjs'), + bundle: true, + platform: 'node', + format: 'esm', + target: 'node20', + sourcemap: false, + logLevel: 'info', +}); + +const ui = await build({ + entryPoints: [join(root, 'src', 'ui', 'main.ts')], + bundle: true, + format: 'iife', + target: 'es2022', + minify: true, + write: false, + logLevel: 'info', +}); + +const bundle = ui.outputFiles[0].text.replace(/<\/script/gi, '<\\/script'); +const css = await readFile(join(root, 'src', 'ui', 'panel.css'), 'utf8'); +const shell = await readFile(join(root, 'src', 'ui', 'shell.html'), 'utf8'); +const html = shell + .replace('/*ARCHIFY_PANEL_CSS*/', () => css) + .replace('/*ARCHIFY_PANEL_BUNDLE*/', () => bundle); + +await writeFile(join(root, 'dist', 'ui', 'panel.html'), html, 'utf8'); +console.log(`面板已生成:dist/ui/panel.html(${html.length} 字节)`); diff --git a/ui-plugins/archify-panel/package.json b/ui-plugins/archify-panel/package.json new file mode 100644 index 00000000..ac97e317 --- /dev/null +++ b/ui-plugins/archify-panel/package.json @@ -0,0 +1,20 @@ +{ + "name": "archify-panel", + "version": "0.2.0", + "private": true, + "type": "module", + "description": "ZCode 侧栏面板插件:用 Archify 生成并查看架构图、流程图、ERD 等图形。", + "license": "Apache-2.0", + "scripts": { + "build": "node build.mjs", + "stage": "node stage.mjs", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@modelcontextprotocol/ext-apps": "^2.0.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "@types/node": "^26.6.6", + "esbuild": "^0.28.2", + "typescript": "^7.0.2" + } +} diff --git a/ui-plugins/archify-panel/src/server/archify.ts b/ui-plugins/archify-panel/src/server/archify.ts new file mode 100644 index 00000000..14a02375 --- /dev/null +++ b/ui-plugins/archify-panel/src/server/archify.ts @@ -0,0 +1,161 @@ +import { spawn } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { archifyCli, workspaceRoot } from './paths.js'; + +export type DiagramType = + | 'architecture' + | 'workflow' + | 'sequence' + | 'dataflow' + | 'lifecycle' + | 'erd' + | 'class' + | 'tree' + | 'timeline' + | 'waterfall'; + +export const diagramTypes: DiagramType[] = [ + 'architecture', + 'workflow', + 'sequence', + 'dataflow', + 'lifecycle', + 'erd', + 'class', + 'tree', + 'timeline', + 'waterfall', +]; + +export interface FinalizeGates { + validate: string; + deliver: string; + check: string; + 'browser-check': string; +} + +export interface FinalizeResult { + ok: boolean; + status: string; + type: string; + quality: string; + artifactPath: string; + artifactBytes: number; + gates: FinalizeGates; + diagnostics: Array<{ code: string; severity: string; message: string }>; + visualReview: string; + durationMs: number; + /** 浏览器校验因缺少 Chrome 被跳过,产物仍然交付 */ + degraded: boolean; + error?: string; +} + +export interface FinalizeOptions { + type: DiagramType; + spec: unknown; + dir: string; + slug: string; + quality: 'standard' | 'showcase'; + repoRoot?: string; +} + +function slugify(value: string): string { + const cleaned = value + .toLowerCase() + .replace(/[^a-z0-9一-龥]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); + return cleaned || 'diagram'; +} + +export function timestamp(): string { + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + return ( + `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` + + `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}` + ); +} + +export async function createArtifactDir(type: string, title: string): Promise<{ dir: string; slug: string }> { + const slug = slugify(title); + const dir = join(workspaceRoot, '.archify-panel', `${type}-${slug}-${timestamp()}`); + await mkdir(dir, { recursive: true }); + return { dir, slug }; +} + +async function runCli(args: string[], cwd: string): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise((resolvePromise) => { + const child = spawn(process.execPath, [archifyCli, ...args], { + cwd, + env: { ...process.env, ARCHIFY_UPDATE_CHECK_DISABLED: '1' }, + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += String(chunk); + }); + child.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + child.on('error', (error) => resolvePromise({ code: -1, stdout, stderr: String(error) })); + child.on('close', (code) => resolvePromise({ code: code ?? 0, stdout, stderr })); + }); +} + +/** 跑 finalize 交付一张图;退出码 2 表示浏览器校验被跳过,产物仍然可用 */ +export async function finalizeDiagram(options: FinalizeOptions): Promise { + const { dir, slug } = options; + const candidatePath = join(dir, 'candidate.json'); + const outputPath = join(dir, `${slug}.html`); + await writeFile(candidatePath, JSON.stringify(options.spec, null, 2), 'utf8'); + + const args = [ + 'finalize', + options.type, + 'candidate.json', + `${slug}.html`, + '--json', + '--quality', + options.quality, + ]; + if (options.repoRoot) args.push('--repo-root', options.repoRoot); + + const run = await runCli(args, dir); + let receipt: Record | undefined; + const start = run.stdout.indexOf('{'); + if (start >= 0) { + try { + receipt = JSON.parse(run.stdout.slice(start)) as Record; + } catch { + receipt = undefined; + } + } + + const gates = (receipt?.gates ?? {}) as Partial; + const diagnostics = (receipt?.diagnostics ?? []) as FinalizeResult['diagnostics']; + const artifact = receipt?.artifact as { path?: string; bytes?: number } | undefined; + const status = String(receipt?.status ?? 'failed'); + const degraded = status === 'skipped' || gates['browser-check'] === 'skipped'; + + return { + ok: run.code === 0 || degraded, + status, + type: String(receipt?.type ?? options.type), + quality: String(receipt?.quality ?? options.quality), + artifactPath: artifact?.path ?? outputPath, + artifactBytes: artifact?.bytes ?? 0, + gates: { + validate: gates.validate ?? 'not-run', + deliver: gates.deliver ?? 'not-run', + check: gates.check ?? 'not-run', + 'browser-check': gates['browser-check'] ?? 'not-run', + }, + diagnostics, + visualReview: String(receipt?.visualReview ?? 'not-requested'), + durationMs: Number(receipt?.durationMs ?? 0), + degraded, + error: run.code !== 0 && !degraded ? run.stderr.trim().slice(0, 2000) || run.stdout.trim().slice(0, 2000) : undefined, + }; +} diff --git a/ui-plugins/archify-panel/src/server/main.ts b/ui-plugins/archify-panel/src/server/main.ts new file mode 100644 index 00000000..fe7528d0 --- /dev/null +++ b/ui-plugins/archify-panel/src/server/main.ts @@ -0,0 +1,314 @@ +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; +import { basename, dirname, join, relative, resolve, sep } from 'node:path'; +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; +import { + createArtifactDir, + diagramTypes, + finalizeDiagram, + type DiagramType, +} from './archify.js'; +import { archifyRoot, assetRoot, workspaceRoot } from './paths.js'; +import { + addDiagram, + getCurrentDiagram, + getDiagram, + getFileHandle, + putFileHandle, + setCurrentDiagram, + type DiagramRecord, +} from './store.js'; + +const PLUGIN_NAME = 'archify-panel'; +const PLUGIN_VERSION = '0.2.0'; +const SURFACE_ID = 'archify'; +const PANEL_URI = `ui://${SURFACE_ID}/panel.html`; +const HTML_MIME = 'text/html;profile=mcp-app'; +const FILE_PREFIX = `archify://file/`; +const CHUNK_BYTES = 4 * 1024 * 1024; + +const server = new McpServer({ name: PLUGIN_NAME, version: PLUGIN_VERSION }); + +type Visibility = 'model' | 'app'; + +function uiMeta(visibility: Visibility[]) { + return { + ui: { + resourceUri: PANEL_URI, + surface: SURFACE_ID, + visibility, + }, + // 声明全屏面板展示:画图需要大空间,避免宿主把图形内嵌在会话里 + 'openai/ui': { preferredModelDisplayMode: 'fullscreen' }, + }; +} + +function textResult(text: string, structuredContent?: Record) { + return { + content: [{ type: 'text' as const, text }], + ...(structuredContent ? { structuredContent } : {}), + }; +} + +function mimeTypeFor(name: string): string { + const extension = name.toLowerCase().split('.').pop(); + if (extension === 'pdf') return 'application/pdf'; + if (extension === 'svg') return 'image/svg+xml'; + if (extension === 'webp') return 'image/webp'; + if (extension === 'jpeg' || extension === 'jpg') return 'image/jpeg'; + return 'image/png'; +} + +function diagramSummary(record: DiagramRecord) { return { + id: record.id, + title: record.title, + type: record.type, + quality: record.quality, + bytes: record.bytes, + createdAt: record.createdAt, + gates: record.gates, + degraded: record.degraded, + htmlPath: record.htmlPath, + }; +} + +server.registerTool( + 'render_diagram', + { + description: + '把一份 Archify JSON IR 渲染成可交互的单文件 HTML 图形,并登记到侧栏面板。可用类型:' + + diagramTypes.join('、') + + '。渲染完成后产物会显示在 ZCode 侧栏,用户可以在图形自带的导出菜单里导出 PNG/SVG/分享卡片。', + inputSchema: { + type: z.enum(diagramTypes as [DiagramType, ...DiagramType[]]), + spec: z.union([z.string(), z.record(z.string(), z.unknown())]), + title: z.string().max(200).optional(), + quality: z.enum(['standard', 'showcase']).optional(), + repoRoot: z.string().max(400).optional(), + }, + _meta: uiMeta(['model', 'app']), + }, + async (input) => { + let spec: unknown = input.spec; + if (typeof spec === 'string') { + try { + spec = JSON.parse(spec); + } catch { + return textResult('spec 不是合法 JSON', { error: { code: 'invalid_spec', message: 'spec 不是合法 JSON' } }); + } + } + const title = input.title?.trim() || '未命名图形'; + const { dir, slug } = await createArtifactDir(input.type, title); + const result = await finalizeDiagram({ + type: input.type, + spec, + dir, + slug, + quality: input.quality ?? 'showcase', + repoRoot: input.repoRoot, + }); + if (!result.ok) { + return textResult(`渲染失败:${result.error ?? '未知错误'}`, { + error: { code: 'render_failed', message: result.error ?? '未知错误' }, + diagnostics: result.diagnostics, + }); + } + const record = await addDiagram({ + title, + type: result.type, + quality: result.quality, + htmlPath: result.artifactPath, + bytes: result.artifactBytes, + gates: result.gates, + degraded: result.degraded, + }); + const gateText = Object.entries(result.gates) + .map(([name, state]) => `${name}=${state}`) + .join(', '); + const note = result.degraded ? ';浏览器校验被跳过,产物仍然交付' : ''; + return textResult( + `已生成 ${result.type} 图:${title}(${gateText}${note})`, + { diagram: diagramSummary(record) }, + ); + }, +); + +server.registerTool( + 'get_authoring_assets', + { + description: + '返回 Archify 的 schema、用例与写作参考的绝对路径。写图前先调用它,拿到路径后读取对应 schema 再编写 JSON IR。', + inputSchema: {}, + // 只关联 visibility,不带面板 surface,避免宿主在产物还没生成时就打开空画布 + _meta: { ui: { visibility: ['model' as Visibility] } }, + }, + async () => { + const schemas = (await readdir(join(archifyRoot, 'schemas'))) + .filter((name) => name.endsWith('.schema.json')) + .map((name) => join(archifyRoot, 'schemas', name)); + const examples = (await readdir(join(archifyRoot, 'examples'))) + .filter((name) => name.endsWith('.json')) + .map((name) => join(archifyRoot, 'examples', name)); + const references = (await readdir(join(archifyRoot, 'references'))) + .filter((name) => name.endsWith('.md')) + .map((name) => join(archifyRoot, 'references', name)); + return textResult(`Archify 写作资源根目录:${archifyRoot}`, { + root: archifyRoot, + schemas, + examples, + references, + }); + }, +); + +server.registerTool( + 'open_diagram', + { + description: '把指定的 Archify 图形设为侧栏面板当前显示的图。', + inputSchema: { id: z.string().min(1) }, + _meta: uiMeta(['model', 'app']), + }, + async (input) => { + const record = await setCurrentDiagram(input.id); + if (!record) { + return textResult('找不到这张图', { error: { code: 'not_found', message: input.id } }); + } + return textResult(`侧栏已切换到:${record.title}`, { diagram: diagramSummary(record) }); + }, +); + +server.registerTool( + 'get_current_diagram', + { + description: '返回侧栏面板当前显示的 Archify 图形信息,面板刷新后用它恢复现场。', + inputSchema: {}, + _meta: uiMeta(['app']), + }, + async () => { + const record = await getCurrentDiagram(); + return textResult(record ? `当前图:${record.title}` : '当前没有图', { + diagram: record ? diagramSummary(record) : null, + }); + }, +); + +server.registerTool( + 'save_export', + { + description: '把面板导出的图片或 PDF 字节写进工作区文件。', + inputSchema: { + name: z.string().min(1).max(200), + data: z.string().min(1), + encoding: z.enum(['base64', 'text']).default('base64'), + directory: z.string().max(400).optional(), + }, + _meta: uiMeta(['app']), + }, + async (input) => { + try { + const directory = resolve(workspaceRoot, input.directory ?? '.archify-panel'); + if (!directory.startsWith(workspaceRoot + sep)) { + return { + isError: true, + ...textResult('导出目录必须位于工作区内', { + error: { code: 'invalid_path', message: input.directory ?? '' }, + }), + }; + } + const target = resolve(directory, basename(input.name)); + if (!target.startsWith(directory + sep)) { + return { + isError: true, + ...textResult('导出文件名非法', { error: { code: 'invalid_path', message: input.name } }), + }; + } + const bytes = Buffer.from(input.data, input.encoding === 'text' ? 'utf8' : 'base64'); + await mkdir(directory, { recursive: true }); + await writeFile(target, bytes); + const token = putFileHandle({ + name: basename(input.name), + mimeType: mimeTypeFor(input.name), + bytes, + }); + return textResult(`已保存:${relative(workspaceRoot, target)}`, { + path: relative(workspaceRoot, target), + bytes: bytes.byteLength, + name: basename(input.name), + mimeType: mimeTypeFor(input.name), + token, + uri: `${FILE_PREFIX}${token}/0`, + }); + } catch (error) { + // 把真实失败原因带回面板,不留兜底文案 + const message = error instanceof Error ? error.message : String(error); + return { + isError: true, + ...textResult(`保存失败:${message}`, { + error: { code: 'save_failed', message }, + }), + }; + } + }, +); + +server.registerResource( + 'panel', + PANEL_URI, + { mimeType: HTML_MIME }, + async () => ({ + contents: [ + { + uri: PANEL_URI, + mimeType: HTML_MIME, + text: await readFile(join(assetRoot, 'panel.html'), 'utf8'), + }, + ], + }), +); + +server.registerResource( + 'artifact-interactive', + new ResourceTemplate(`archify://artifact-interactive/{id}`, { list: undefined }), + {}, + async (uri, { id }) => { + const record = await getDiagram(String(id)); + if (!record) throw new Error(`找不到图形 ${id}`); + const html = await readFile(record.htmlPath, 'utf8'); + return { + contents: [ + { + uri: uri.href, + mimeType: 'text/html', + text: html, + }, + ], + }; + }, +); + +server.registerResource( + 'file', + new ResourceTemplate(`${FILE_PREFIX}{token}/{index}`, { list: undefined }), + {}, + async (uri, { token, index }) => { + const handle = getFileHandle(String(token)); + if (!handle) throw new Error('导出文件已过期'); + const position = Number(index); + const start = position * CHUNK_BYTES; + const slice = handle.bytes.subarray(start, start + CHUNK_BYTES); + return { + contents: [ + { + uri: uri.href, + mimeType: handle.mimeType, + blob: slice.toString('base64'), + }, + ], + }; + }, +); + +const transport = new StdioServerTransport(); +await server.connect(transport); +console.error(`[${PLUGIN_NAME}] 已启动,工作区 ${workspaceRoot},资产目录 ${dirname(assetRoot)}`); diff --git a/ui-plugins/archify-panel/src/server/paths.ts b/ui-plugins/archify-panel/src/server/paths.ts new file mode 100644 index 00000000..b9e9b349 --- /dev/null +++ b/ui-plugins/archify-panel/src/server/paths.ts @@ -0,0 +1,37 @@ +import { createHash } from 'node:crypto'; +import { existsSync, realpathSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const moduleDir = dirname(fileURLToPath(import.meta.url)); + +/** 从模块位置向上找插件根目录,兼容 src/server 与 dist 两种布局 */ +function findPluginRoot(): string { + let current = moduleDir; + for (let depth = 0; depth < 6; depth += 1) { + if (existsSync(join(current, 'vendor', 'archify', 'bin', 'archify.mjs'))) return current; + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + throw new Error('找不到 vendor/archify,插件目录不完整'); +} + +export const pluginRoot = findPluginRoot(); +export const archifyRoot = join(pluginRoot, 'vendor', 'archify'); +export const archifyCli = join(archifyRoot, 'bin', 'archify.mjs'); +export const assetRoot = join(pluginRoot, 'dist', 'ui'); + +export const workspaceRoot = realpathSync( + resolve(process.env.ZCODE_WORKSPACE_ROOT || process.cwd()), +); + +export const artifactRoot = join(workspaceRoot, '.archify-panel'); + +/** 插件私有数据目录按工作区隔离,避免不同项目的图互相串味 */ +export const dataRoot = (() => { + const base = process.env.ZCODE_PLUGIN_DATA || join(homedir(), '.zcode', 'plugin-data', 'archify-panel'); + const key = process.env.ZCODE_WORKSPACE_IDENTITY?.trim() || workspaceRoot; + return join(base, createHash('sha256').update(key).digest('hex')); +})(); diff --git a/ui-plugins/archify-panel/src/server/store.ts b/ui-plugins/archify-panel/src/server/store.ts new file mode 100644 index 00000000..cbba1abb --- /dev/null +++ b/ui-plugins/archify-panel/src/server/store.ts @@ -0,0 +1,95 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { dataRoot } from './paths.js'; +import type { FinalizeGates } from './archify.js'; + +export interface DiagramRecord { + id: string; + title: string; + type: string; + quality: string; + htmlPath: string; + bytes: number; + createdAt: string; + gates: FinalizeGates; + degraded: boolean; +} + +interface IndexFile { + currentId: string | null; + diagrams: DiagramRecord[]; +} + +export interface FileHandle { + name: string; + mimeType: string; + bytes: Buffer; +} + +const indexPath = join(dataRoot, 'index.json'); +const handles = new Map(); +const MAX_HANDLES = 8; + +async function readIndex(): Promise { + try { + const raw = await readFile(indexPath, 'utf8'); + const parsed = JSON.parse(raw) as IndexFile; + if (Array.isArray(parsed.diagrams)) return parsed; + } catch { + // 索引缺失或损坏时从空索引重建 + } + return { currentId: null, diagrams: [] }; +} + +async function writeIndex(index: IndexFile): Promise { + await mkdir(dataRoot, { recursive: true }); + await writeFile(indexPath, JSON.stringify(index, null, 2), 'utf8'); +} + +export async function addDiagram(record: Omit): Promise { + const index = await readIndex(); + const entry: DiagramRecord = { + ...record, + id: randomUUID(), + createdAt: new Date().toISOString(), + }; + index.diagrams = [entry, ...index.diagrams.filter((item) => item.htmlPath !== entry.htmlPath)].slice(0, 50); + index.currentId = entry.id; + await writeIndex(index); + return entry; +} + +export async function getDiagram(id: string): Promise { + const index = await readIndex(); + return index.diagrams.find((item) => item.id === id); +} + +export async function getCurrentDiagram(): Promise { + const index = await readIndex(); + if (!index.currentId) return undefined; + return index.diagrams.find((item) => item.id === index.currentId); +} + +export async function setCurrentDiagram(id: string): Promise { + const index = await readIndex(); + const entry = index.diagrams.find((item) => item.id === id); + if (!entry) return undefined; + index.currentId = id; + await writeIndex(index); + return entry; +} + +export function putFileHandle(handle: FileHandle): string { + const token = randomUUID(); + handles.set(token, handle); + while (handles.size > MAX_HANDLES) { + const oldest = handles.keys().next().value; + if (oldest) handles.delete(oldest); + } + return token; +} + +export function getFileHandle(token: string): FileHandle | undefined { + return handles.get(token); +} diff --git a/ui-plugins/archify-panel/src/ui/main.ts b/ui-plugins/archify-panel/src/ui/main.ts new file mode 100644 index 00000000..628705cb --- /dev/null +++ b/ui-plugins/archify-panel/src/ui/main.ts @@ -0,0 +1,258 @@ +import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps'; + +interface DiagramMeta { + id: string; + title: string; + type: string; + quality: string; + bytes: number; + createdAt: string; + gates: Record; + degraded: boolean; + htmlPath: string; +} + +const app = new App( + { name: 'archify-panel', version: '0.2.0' }, + { tools: { listChanged: true }, availableDisplayModes: ['inline', 'fullscreen'] }, + { autoResize: false }, +); + +const state = { + diagram: null as DiagramMeta | null, + theme: 'light' as 'light' | 'dark', +}; + +const INTERACTIVE_URI = (id: string) => `archify://artifact-interactive/${id}`; + +function element( + tag: K, + attributes: Record = {}, + children: Array = [], +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + for (const [key, value] of Object.entries(attributes)) { + node.setAttribute(key, value); + } + for (const child of children) { + node.append(child); + } + return node; +} + +const root = document.getElementById('root'); +if (!root) throw new Error('面板缺少根节点'); + +const stage = element('iframe', { + sandbox: 'allow-scripts allow-same-origin', + referrerpolicy: 'no-referrer', + title: 'Archify 图形', +}); + +const hint = element('div', { class: 'hint' }, ['还没有图形,请让 Agent 生成一张']); +const toast = element('div', { class: 'toast', role: 'status' }); + +root.replaceChildren(element('main', { class: 'stage' }, [stage, hint, toast])); + +const TOAST_MS = 3000; +let toastTimer: ReturnType | undefined; + +/** 提示只在出错或导出结果时浮出一小会儿,平时不留常驻栏 */ +function setStatus(message: string, isError = false): void { + toast.textContent = message; + toast.className = isError ? 'toast error visible' : 'toast visible'; + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(() => { + toast.className = 'toast'; + }, TOAST_MS); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** 宿主按内容高度摆放面板时,靠这个通知保持面板撑满 */ +function reportSize(): void { + void app + .sendSizeChanged({ width: window.innerWidth, height: window.innerHeight }) + .catch(() => undefined); +} + +async function callTool(name: string, args: Record = {}): Promise> { + const result = await app.callServerTool({ name, arguments: args }); + const structured = result.structuredContent as Record | undefined; + if (result.isError) { + const error = structured?.error as { message?: string } | undefined; + throw new Error(error?.message ?? `${name} 调用失败`); + } + return structured ?? {}; +} + +function applyTheme(): void { + document.documentElement.setAttribute('data-theme', state.theme); + const inner = stage.contentDocument; + if (inner?.documentElement) { + inner.documentElement.setAttribute('data-theme', state.theme); + } +} + +async function loadArtifact(diagram: DiagramMeta): Promise { + const result = await app.readServerResource({ uri: INTERACTIVE_URI(diagram.id) }); + const content = result.contents[0]; + if (!content || !('text' in content) || typeof content.text !== 'string') { + throw new Error('产物内容为空'); + } + stage.addEventListener( + 'load', + () => { + applyTheme(); + installExportBridge(); + }, + { once: true }, + ); + stage.srcdoc = content.text; +} + +async function showDiagram(diagram: DiagramMeta): Promise { + state.diagram = diagram; + hint.hidden = true; + try { + await loadArtifact(diagram); + await sleep(120); + applyTheme(); + await reportContext(); + } catch (error) { + setStatus(`载入失败:${(error as Error).message}`, true); + } +} + +async function reportContext(): Promise { + if (!state.diagram) return; + const diagram = state.diagram; + try { + await app.updateModelContext({ + content: [ + { + type: 'text', + text: + `侧栏当前显示 Archify 图形:${diagram.title}(类型 ${diagram.type},id ${diagram.id})。` + + '需要细节时用 render_diagram 的同一份 JSON IR 或直接问这张图。', + }, + ], + structuredContent: { + kind: 'active-diagram', + diagramId: diagram.id, + title: diagram.title, + type: diagram.type, + htmlPath: diagram.htmlPath, + }, + }); + } catch { + // 宿主不支持上下文回注时静默降级 + } +} + +async function saveAndOffer(blob: Blob, name: string, mimeType: string): Promise { + try { + const base64 = await blobToBase64(blob); + const structured = await callTool('save_export', { + name, + data: base64, + encoding: 'base64', + directory: '.archify-panel/exports', + }); + const uri = structured.uri as string | undefined; + const path = structured.path as string | undefined; + const where = path ? `(${path})` : ''; + setStatus(`已导出 ${name}${where}`); + if (uri) { + try { + await app.downloadFile({ contents: [{ type: 'resource_link', uri, mimeType, name }] }); + } catch { + setStatus(`已导出 ${name}${where},宿主未弹出另存为`); + } + } + } catch (error) { + setStatus(`导出失败:${(error as Error).message}`, true); + } +} + +function blobToBase64(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = String(reader.result); + resolve(result.slice(result.indexOf(',') + 1)); + }; + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }); +} + +/** 产物自带导出在沙箱 iframe 里会被浏览器拦截,截获导出二进制并转交宿主保存 */ +function installExportBridge(): void { + const frame = stage.contentWindow as (Window & typeof globalThis) | null; + if (!frame) return; + const blobsByUrl = new Map(); + const originalCreateObjectURL = frame.URL.createObjectURL; + frame.URL.createObjectURL = function patched(blob: Blob | MediaSource): string { + const url = originalCreateObjectURL.call(frame.URL, blob); + // 产物在内层框架的另一个 realm 里创建 Blob,跨 realm 的 instanceof 恒为假,只能按结构判断 + if (blob && typeof (blob as Blob).type === 'string') blobsByUrl.set(url, blob as Blob); + return url; + }; + const originalAnchorClick = frame.HTMLAnchorElement.prototype.click; + frame.HTMLAnchorElement.prototype.click = function patched(this: HTMLAnchorElement): void { + const name = this.getAttribute('download'); + const href = this.getAttribute('href') ?? ''; + const blob = name ? blobsByUrl.get(href) : undefined; + if (!blob || !name) { + originalAnchorClick.call(this); + return; + } + blobsByUrl.delete(href); + void saveAndOffer(blob, name, blob.type || 'application/octet-stream'); + }; +} + +app.ontoolresult = (result) => { + const structured = result.structuredContent as { diagram?: DiagramMeta } | undefined; + if (structured?.diagram) { + void showDiagram(structured.diagram); + } +}; + +app.onhostcontextchanged = () => { + const theme = app.getHostContext()?.theme; + if (theme === 'light' || theme === 'dark') { + state.theme = theme; + applyTheme(); + } +}; + +async function boot(): Promise { + try { + await app.connect(new PostMessageTransport(window.parent, window.parent)); + } catch (error) { + setStatus(`连接宿主失败:${(error as Error).message}`, true); + return; + } + const theme = app.getHostContext()?.theme; + if (theme === 'light' || theme === 'dark') state.theme = theme; + applyTheme(); + reportSize(); + window.addEventListener('resize', reportSize); + try { + const structured = await callTool('get_current_diagram'); + const diagram = structured.diagram as DiagramMeta | undefined; + if (diagram) { + await showDiagram(diagram); + } else { + hint.hidden = false; + } + } catch (error) { + setStatus(`读取当前图失败:${(error as Error).message}`, true); + } +} + +void boot(); diff --git a/ui-plugins/archify-panel/src/ui/panel.css b/ui-plugins/archify-panel/src/ui/panel.css new file mode 100644 index 00000000..ded66141 --- /dev/null +++ b/ui-plugins/archify-panel/src/ui/panel.css @@ -0,0 +1,102 @@ +:root { + --panel-bg: #ffffff; + --panel-fg: #1f2328; + --panel-muted: #6b7280; + --panel-border: #e5e7eb; + --panel-surface: #f6f7f9; + --panel-accent: #2563eb; + --panel-accent-fg: #ffffff; + --panel-danger: #b91c1c; +} + +html[data-theme='dark'] { + --panel-bg: #17181c; + --panel-fg: #e8eaed; + --panel-muted: #9aa0a6; + --panel-border: #2c2e35; + --panel-surface: #1f2126; + --panel-accent: #4f8bff; + --panel-accent-fg: #0b0d10; + --panel-danger: #f87171; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; + background: var(--panel-bg); + color: var(--panel-fg); + font: 13px/1.5 system-ui, -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif; +} + +#root { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.boot { + padding: 24px; + color: var(--panel-muted); +} + +.stage { + position: relative; + flex: 1 1 auto; + min-height: 0; + display: flex; +} + +.stage iframe { + flex: 1 1 auto; + width: 100%; + height: 100%; + border: 0; + background: var(--panel-bg); +} + +.hint { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--panel-muted); + font-size: 13px; + pointer-events: none; +} + +.hint[hidden] { + display: none; +} + +.toast { + position: absolute; + left: 50%; + bottom: 14px; + transform: translateX(-50%); + max-width: calc(100% - 24px); + padding: 6px 12px; + border: 1px solid var(--panel-border); + border-radius: 6px; + background: var(--panel-surface); + color: var(--panel-muted); + font-size: 12px; + opacity: 0; + pointer-events: none; + transition: opacity 160ms ease; +} + +.toast.visible { + opacity: 1; +} + +.toast.error { + color: var(--panel-danger); + border-color: var(--panel-danger); +} diff --git a/ui-plugins/archify-panel/src/ui/shell.html b/ui-plugins/archify-panel/src/ui/shell.html new file mode 100644 index 00000000..387d8dee --- /dev/null +++ b/ui-plugins/archify-panel/src/ui/shell.html @@ -0,0 +1,19 @@ + + + + + + Archify 画板 + + + +
+
正在连接宿主…
+
+ + + diff --git a/ui-plugins/archify-panel/stage.mjs b/ui-plugins/archify-panel/stage.mjs new file mode 100644 index 00000000..b6c55a86 --- /dev/null +++ b/ui-plugins/archify-panel/stage.mjs @@ -0,0 +1,11 @@ +import { cp } from "node:fs/promises"; +import { join } from "node:path"; + +// 把打包的 Archify 运行时复制进安装目录 +const installDir = process.env.ZCODE_PLUGIN_INSTALL_DIR; +if (!installDir) throw new Error("ZCODE_PLUGIN_INSTALL_DIR is not set"); + +await cp(join(import.meta.dirname, "vendor"), join(installDir, "vendor"), { + recursive: true, + dereference: true, +}); diff --git a/ui-plugins/archify-panel/test/cdp.mjs b/ui-plugins/archify-panel/test/cdp.mjs new file mode 100644 index 00000000..8400d61d --- /dev/null +++ b/ui-plugins/archify-panel/test/cdp.mjs @@ -0,0 +1,115 @@ +import { spawn } from 'node:child_process'; + +const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + +export async function launchChrome(port = 9333) { + const child = spawn( + CHROME, + [ + '--headless=new', + '--disable-gpu', + '--no-sandbox', + '--hide-scrollbars', + `--remote-debugging-port=${port}`, + '--user-data-dir=/tmp/archify-cdp-profile', + 'about:blank', + ], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + + const endpoint = await new Promise((resolve, reject) => { + let buffer = ''; + const timer = setTimeout(() => reject(new Error('Chrome 没有报告调试端口')), 30000); + child.stderr.on('data', (chunk) => { + buffer += String(chunk); + const match = buffer.match(/DevTools listening on (ws:\/\/\S+)/); + if (match) { + clearTimeout(timer); + resolve(match[1]); + } + }); + }); + + const socket = new WebSocket(endpoint); + await new Promise((resolve, reject) => { + socket.addEventListener('open', resolve, { once: true }); + socket.addEventListener('error', reject, { once: true }); + }); + + let nextId = 1; + const pending = new Map(); + const listeners = []; + + socket.addEventListener('message', (event) => { + const message = JSON.parse(String(event.data)); + if (message.id && pending.has(message.id)) { + const { resolve, reject } = pending.get(message.id); + pending.delete(message.id); + if (message.error) reject(new Error(message.error.message)); + else resolve(message.result); + return; + } + for (const listener of listeners) listener(message); + }); + + const send = (method, params = {}, sessionId) => + new Promise((resolve, reject) => { + const id = nextId++; + pending.set(id, { resolve, reject }); + socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); + }); + + return { + send, + on(listener) { + listeners.push(listener); + }, + close() { + socket.close(); + child.kill(); + }, + }; +} + +export async function openPage(client, url) { + const { targetId } = await client.send('Target.createTarget', { url: 'about:blank' }); + const { sessionId } = await client.send('Target.attachToTarget', { targetId, flatten: true }); + const logs = []; + client.on((message) => { + if (message.sessionId !== sessionId) return; + if (message.method === 'Runtime.consoleAPICalled') { + logs.push( + `${message.params.type}: ${message.params.args.map((arg) => arg.value ?? arg.description ?? '').join(' ')}`, + ); + } + if (message.method === 'Runtime.exceptionThrown') { + logs.push(`exception: ${message.params.exceptionDetails.text} ${message.params.exceptionDetails.exception?.description ?? ''}`); + } + }); + await client.send('Runtime.enable', {}, sessionId); + await client.send('Page.enable', {}, sessionId); + await client.send('Page.navigate', { url }, sessionId); + return { sessionId, logs }; +} + +export async function evaluate(client, sessionId, expression) { + const result = await client.send( + 'Runtime.evaluate', + { expression, awaitPromise: true, returnByValue: true }, + sessionId, + ); + if (result.exceptionDetails) { + throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text); + } + return result.result.value; +} + +export async function waitFor(client, sessionId, expression, timeoutMs = 30000, intervalMs = 300) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = await evaluate(client, sessionId, expression); + if (value) return value; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + return undefined; +} diff --git a/ui-plugins/archify-panel/test/panel-harness.mjs b/ui-plugins/archify-panel/test/panel-harness.mjs new file mode 100644 index 00000000..5f587246 --- /dev/null +++ b/ui-plugins/archify-panel/test/panel-harness.mjs @@ -0,0 +1,115 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const outputDir = process.argv[2] || '/tmp/archify-panel-harness'; +mkdirSync(outputDir, { recursive: true }); + +const artifactPath = process.argv[3]; +if (!artifactPath) { + console.error('用法:node test/panel-harness.mjs <输出目录> <产物 html>'); + process.exit(1); +} + +const artifact = readFileSync(artifactPath, 'utf8'); +const artifactBase64 = Buffer.from(artifact, 'utf8').toString('base64'); +const panel = readFileSync(join(root, 'dist', 'ui', 'panel.html'), 'utf8'); +writeFileSync(join(outputDir, 'panel.html'), panel, 'utf8'); + +const diagram = { + id: 'test-diagram', + title: '测试图', + type: 'workflow', + quality: 'showcase', + bytes: artifact.length, + createdAt: new Date().toISOString(), + gates: { validate: 'pass', deliver: 'pass', check: 'pass', 'browser-check': 'pass' }, + degraded: false, + htmlPath: artifactPath, +}; + +const harness = ` +WAITING + + +`; + +writeFileSync(join(outputDir, 'harness.html'), harness, 'utf8'); +console.log(join(outputDir, 'harness.html')); diff --git a/ui-plugins/archify-panel/test/panel-run.mjs b/ui-plugins/archify-panel/test/panel-run.mjs new file mode 100644 index 00000000..2a0ed412 --- /dev/null +++ b/ui-plugins/archify-panel/test/panel-run.mjs @@ -0,0 +1,40 @@ +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { extname, join } from 'node:path'; +import { evaluate, launchChrome, openPage, waitFor } from './cdp.mjs'; + +const harnessDir = process.argv[2] || '/tmp/archify-panel-harness'; + +const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8' }; +const server = createServer(async (request, response) => { + const name = (request.url ?? '/').split('?')[0] === '/' ? '/harness.html' : request.url; + try { + const body = await readFile(join(harnessDir, name)); + response.writeHead(200, { 'content-type': types[extname(name)] ?? 'application/octet-stream' }); + response.end(body); + } catch { + response.writeHead(404); + response.end('not found'); + } +}); +await new Promise((resolve) => server.listen(8732, '127.0.0.1', resolve)); + +const client = await launchChrome(); +const { sessionId, logs } = await openPage(client, 'http://127.0.0.1:8732/harness.html'); + +const title = await waitFor( + client, + sessionId, + "document.title.startsWith('RESULT') || document.title.startsWith('ERROR') ? document.title : ''", + 60000, +); +console.log('面板结果:', title); +console.log('控制台输出:'); +for (const line of logs.slice(0, 25)) console.log(' ', line); + +const status = await evaluate(client, sessionId, 'document.title'); +console.log('最终标题:', status); + +client.close(); +server.close(); +process.exit(0); diff --git a/ui-plugins/archify-panel/test/smoke.mjs b/ui-plugins/archify-panel/test/smoke.mjs new file mode 100644 index 00000000..df5dfed3 --- /dev/null +++ b/ui-plugins/archify-panel/test/smoke.mjs @@ -0,0 +1,53 @@ +import { mkdir, readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const workspace = process.argv[2] || root; +await mkdir(workspace, { recursive: true }); + +const transport = new StdioClientTransport({ + command: process.execPath, + args: [join(root, 'dist', 'server.mjs')], + cwd: workspace, + env: { ...process.env, ZCODE_WORKSPACE_ROOT: workspace }, +}); + +const client = new Client({ name: 'smoke', version: '0.1.0' }); +await client.connect(transport); + +const tools = await client.listTools(); +console.log('工具:', tools.tools.map((tool) => tool.name).join(', ')); + +const spec = JSON.parse( + await readFile(join(root, 'vendor', 'archify', 'examples', 'agent-tool-call.workflow.json'), 'utf8'), +); +const rendered = await client.callTool({ + name: 'render_diagram', + arguments: { type: 'workflow', spec, title: '冒烟测试图' }, +}); +console.log('渲染:', JSON.stringify(rendered.structuredContent)); + +const diagramId = rendered.structuredContent?.diagram?.id; +const artifact = await client.readResource({ uri: `archify://artifact-interactive/${diagramId}` }); +const html = artifact.contents[0]?.text ?? ''; +console.log('产物长度:', html.length, '含导出菜单:', html.includes('exportMenu')); + +const current = await client.callTool({ name: 'get_current_diagram', arguments: {} }); +console.log('当前图:', JSON.stringify(current.structuredContent)); + +const saved = await client.callTool({ + name: 'save_export', + arguments: { + name: '冒烟测试.png', + data: Buffer.from('png-bytes').toString('base64'), + encoding: 'base64', + directory: '.archify-panel/exports', + }, +}); +if (saved.isError) throw new Error(`save_export 失败:${JSON.stringify(saved.content)}`); +console.log('导出:', JSON.stringify(saved.structuredContent)); + +await client.close(); diff --git a/ui-plugins/archify-panel/tsconfig.json b/ui-plugins/archify-panel/tsconfig.json new file mode 100644 index 00000000..92cc1c79 --- /dev/null +++ b/ui-plugins/archify-panel/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/ui-plugins/archify-panel/vendor/archify/LICENSE b/ui-plugins/archify-panel/vendor/archify/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/ui-plugins/archify-panel/vendor/archify/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/ui-plugins/archify-panel/vendor/archify/LICENSE-MIT b/ui-plugins/archify-panel/vendor/archify/LICENSE-MIT new file mode 100644 index 00000000..0f9f9397 --- /dev/null +++ b/ui-plugins/archify-panel/vendor/archify/LICENSE-MIT @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 tt-a1i (Archify) +Copyright (c) 2025 Cocoon AI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ui-plugins/archify-panel/vendor/archify/NOTICE b/ui-plugins/archify-panel/vendor/archify/NOTICE new file mode 100644 index 00000000..b63fbccc --- /dev/null +++ b/ui-plugins/archify-panel/vendor/archify/NOTICE @@ -0,0 +1,26 @@ +Archify +Copyright (c) 2026 tt-a1i (Archify) +Copyright (c) 2025 Cocoon AI + +Archify is distributed under the Apache License, Version 2.0 (LICENSE), +except where a file, package manifest, or third-party notice specifies otherwise. +The separately versioned DeepSeek Harness adapter remains MIT; its bundled +Skill retains the license of the immutable source selected in release.json. + +Code and contributions received under the MIT License through pre-transition +commit 25cf37080fc8b1dbe758a4efc3935b55f3327083 remain available under their +original terms. Previously published releases keep their existing licenses. The complete historical MIT +copyright and permission notice is preserved in LICENSE-MIT. This transition +does not revoke existing MIT grants or assert a new patent grant on behalf +of historical contributors. + +Archify originated from Cocoon-AI/architecture-diagram-generator: +https://github.com/Cocoon-AI/architecture-diagram-generator + +Third-party components retain their own licenses; see THIRD_PARTY_NOTICES.md +and the license files beside those components. Names and logos do not imply +official endorsement, and the software license grants no general trademark +permission. + +These software licenses cover Archify's embedded runtime and templates, not +ownership of a user's authored content, business data, or source repository. diff --git a/ui-plugins/archify-panel/vendor/archify/SKILL.md b/ui-plugins/archify-panel/vendor/archify/SKILL.md new file mode 100644 index 00000000..380de2c1 --- /dev/null +++ b/ui-plugins/archify-panel/vendor/archify/SKILL.md @@ -0,0 +1,137 @@ +--- +name: archify +description: "Create validated architecture, workflow, sequence, data-flow, lifecycle/state, ERD, class, tree, timeline, and waterfall diagrams as explorable standalone HTML with dark/light themes, motion and image/SVG/WebM export. Accept requirements or Mermaid; inspect repository evidence for code-backed diagrams. Use when visualizing systems, API calls, pipelines, schemas, histories, timings, or everyday steps, parts, relationships and states. Also turn an answer into a one-page explainer, with the conclusion first, structured panels, native diagrams and charts, when the user asks for a page, an explainer or an answer to share (做成一页, 用页面讲), or proactively when the user's rules opt in with an [archify explainer: ...] line. Not for dashboards." +license: Apache-2.0 +metadata: + version: "3.2" + author: tt-a1i +--- + +# Archify + +Create checked standalone HTML as a native diagram or a one-page explainer that answers a question. + +Run commands from your working directory. Unless the user names another location, give each new artifact request its own folder `.archify/--/` there (local time, chosen once): keep the candidate and `.html` in it and reuse it for repairs. Native diagrams use `candidate.json` with `meta.output` relative to the working directory; explainers may use `candidate.archify.md` and a portable output filename. A later request gets a new folder. Replace `bin/archify.mjs` in every command with the installed package's absolute path, or its path relative to your working directory; CLI input and output paths resolve from that directory. + +For a native diagram of a real codebase, read [Repository authoring](references/repository-authoring.md) while tracing the requested behavior. A system description uses the native steps below; a frozen candidate uses the handoff path. + +## Choose the artifact + +- When the user asks to draw or visualize a system, flow, schema or history, use a native diagram and the Type router. +- Use `explainer` when the user asks for the answer as a page: a page, one page, an explainer, something to share (“做成一页”, “用页面讲”, “answer with a page”). Without such a request, answer in text or draw the requested diagram. A page made on request ends its reply with the one-line mode offer under Output. +- A rules line in context changes this. `[archify explainer: proactive]`: also make a page, unasked, for an explanation, comparison, trade-off, decision, diagnosis, review or plan that needs a table, steps or several sections. `[archify explainer: always-on]`: every reply that reaches a conclusion also gets a compact page of at most four panels, delivered without opening it, its link closing the reply. `[archify explainer: on-demand]`: only when asked, and never offer a mode. +- In every mode a page needs a conclusion with some structure behind it: chat, one-line answers, raw command output and text-only requests get none. +- For `explainer`, read [Explainer authoring](references/explainer-authoring.md) for its conclusion, reasons, scope, board layout and optional native diagrams. Write the complete draft, then run `finalize explainer` for validation, atomic delivery and artifact checks. Use panel patch for local content revisions. + +## Existing candidate handoff + +When the user supplies a frozen candidate, run `finalize` first as one CLI invocation. Its passing receipt completes the automated gates; follow any visual review recommendation under Delivery before claiming visual quality. For repair, follow step 5. + +`finalize` includes a bounded update check in its delivery receipt; see Update awareness. + +## Native diagram authoring + +Use this path for ordinary generation. Read branch references only when their stated trigger applies. + +1. Choose `architecture`, `workflow`, `sequence`, `dataflow`, `lifecycle`, `erd`, `class`, `tree`, `timeline`, or `waterfall` from the question. +2. Use the exact schema and example paths in the Type router without listing their directories. Read [Authoring defaults](references/authoring-defaults.md) and the mode's example in a bounded batch separate from project documents and complete schemas so neither is truncated; recover any missing section before writing. For Architecture, use the matching showcase example. For every mode except Architecture, also read the mode and common schemas. Read the relevant schema definition before choosing any new field, enum, or constrained text, especially boundary kinds. Examples teach shape, not facts. Use fresh IDs, wording, and layout. Go directly to the candidate without preliminary help, doctor, starter validation, temporary diagrams, or output-path listing. Query brands only for an explicitly requested mark; read [Brand marks](references/brand-marks.md) for an unknown mark with a user-provided URL. +3. Once the requested scope and, for a real codebase, [source evidence](references/repository-authoring.md) are covered, write the complete candidate directly without planning coordinates in prose. Choose Architecture abstraction and connected placement using Authoring defaults before coordinates: show the main user journey and necessary branches, preserve control roles and behavior-changing conditions, and leave enough room for actual relationship labels. No node, relationship, source, view, card, or boundary count is a target or ceiling. Use automatic routes first; add explicit routing only for necessary branch, return, supplied geometry, or measured repair. Set `meta.quality_profile` to `"showcase"` unless the user requests dense `standard`. For ERD, use a table-first layout: show every physical column in `entities[].attributes`, keep its SQL type and source comment, state every key role a column carries (`key` takes a role or a list, so a junction column is `["pk", "fk"]`, and every `fk` column also needs `references` like `"orders.id"`), and never move columns into cards as a substitute for the table. Classify tables into functional domains before placement, give each domain a concise `tag`, and place its members in one solid rectangle of grid cells — a single row, a single column, or a filled rectangular block, not an L or diagonal. A tagged table always needs `row`/`col`, because absolute coordinates have no grid cells and a domain whose tables do not fill one rectangle cannot be drawn as a band: the renderer reports `erd/domain-not-drawn` instead of publishing a diagram that cannot name the domain. A multi-domain schema reads best as one domain per column — each `tag` a vertical run — because the reader scales a wide canvas up to the reader width and shrinks a tall one until the page fits, so the wide arrangement is the one that stays complete and legible inside a laptop viewport. A schema-first ERD over roughly 8 tables or 50 fields is itself a dense map: author its first complete-field pass at `standard` and upgrade only once the layout still reads. +4. Once the complete first candidate is written, run `finalize` directly. Its first gate is showcase validation; successful first drafts need no separate pre-validation. Keep the candidate unchanged while the command runs: + + ```bash + node bin/archify.mjs finalize --quality showcase --json + ``` + + For a repository-backed candidate, include evidence on the first draft and use the complete first command: `node bin/archify.mjs finalize --repo-root --quality showcase --json`. Pass the `--quality` that matches `meta.quality_profile`, such as `standard` for a dense first ERD pass. + + A passing receipt proves the included `validate`, `deliver`, strict `check`, and real-browser `browser-check` gates passed. Use its compact summary; run standalone commands only for a separate request or focused failure diagnosis. + +5. A non-zero exit is never success. Read compact stdout or `evidence.summaryReceipt`, then [repair the failed gate](references/delivery-contract.md#failed-finalize-and-candidate-repair), including its repair limit. Preserve requested meaning and source evidence. For several tangled Architecture routes, read [Architecture layout repair](references/architecture-layout-repair.md); for measured field or geometry failures, read [Authoring contract](references/authoring-contract.md). Edit the connected neighborhood and rerun the complete `finalize` command from step 4. Once a run has reached `browser-check`, the output path owns that evidence: give every later run its own `--out-dir /review-`. + +## Update awareness + +`finalize` and standalone `deliver` include `update` in their receipts. Do not run a separate check for the same delivery. If `update.noticeRequired` is true, read `references/update-awareness.md` and keep one compact update notice in your final response to the user, even after a quality gate fails. For a task with several diagrams, mention the update once in the final response. Snooze or ignore a reminder only when the user explicitly asks; never install or update on your own initiative. + +Before the first candidate, use the authoring references and relevant repository source, not Archify implementation or tests. Inspect Archify implementation if diagnostics remain unactionable after focused repairs. + + +## Type router + +| Type | Use for | Schema | Example | +|---|---|---|---| +| `architecture` | Components, services, cloud/security boundaries, infrastructure; what something everyday is made of | `schemas/architecture.schema.json` | System descriptions, services, libraries, and CLI repos: `examples/web-app.architecture.json`; deployment repos: `examples/production-deployment.architecture.json` | +| `workflow` | Processes, approval gates, tool calls, runbooks, CI/CD; plans and step-by-step life processes | `schemas/workflow.schema.json` | `examples/agent-tool-call.workflow.json` | +| `sequence` | API call chains, request lifecycles, async traces, returns; back-and-forth between people | `schemas/sequence.schema.json` | `examples/cache-miss-request.sequence.json` | +| `dataflow` | Pipelines, ETL/ELT, lineage, governance, consumers; where money or documents go | `schemas/dataflow.schema.json` | `examples/product-analytics.dataflow.json` | +| `lifecycle` | State/status transitions, retries, waiting and terminal states; where an application or order stands | `schemas/lifecycle.schema.json` | `examples/deployment-release.lifecycle.json` | +| `erd` | Tables/entities, attributes, primary/foreign/unique keys, cardinality between them | `schemas/erd.schema.json` | `examples/orders.erd.json` | +| `tree` | Single-parent containment: repository/module trees, capability or feature breakdowns, org-style decomposition | `schemas/tree.schema.json` | `examples/payment-platform.tree.json` | +| `class` | Classes, interfaces, selected members, and dependency/association/inheritance/realization/composition between types | `schemas/class.schema.json` | `examples/payments.class.json` | +| `timeline` | When things happened: incident reviews, release histories, timestamped events on a proportional clock with source lanes | `schemas/timeline.schema.json` | `examples/payment-incident.timeline.json` | +| `waterfall` | Where the time went: span durations, overlap, and nesting of one request or agent run on a common time axis | `schemas/waterfall.schema.json` | `examples/checkout-request.waterfall.json` | + +When choosing among native diagram types is ambiguous, run `node bin/archify.mjs guide "" --json`. Scenario proof examples are structural references, not facts to copy. + +For an everyday subject, keep the same diagram modes and semantic types, then name them for the reader: use everyday `icon` values and `meta.legend` labels as in [Node icons](references/authoring-contract.md#node-icons). Ask for missing personal facts instead of inventing dates, amounts, or rules. + +## 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. +- `erDiagram` → `erd`; entity and relationship names, keys, and cardinality retain meaning, not Mermaid style. +- `classDiagram` → `class`; type kinds, selected members, and relationship kinds retain meaning, not Mermaid style. + +## Delivery + +Use the `finalize` command above for the first candidate and after a repair. + +`finalize` stops at the first non-passing gate. Its compact stdout and `.finalize-summary.json` are ordinary evidence. A passing run creates no screenshots and reports `visualReview: "not-requested"`. + +When a passing Architecture receipt reports `visualReviewRecommendation.signals.resolvedCrossovers`, copy the candidate aside and apply the hints in one edit that changes only node positions and sizes: every node, relationship (including its `from` and `to`), label, and source stays as it was. Rerun the complete `finalize` once with `--out-dir /review-2`, because the previous HTML already owns its browser evidence. `--out-dir` relocates the new browser-check and default finalize receipts, not the HTML or its delivery receipt. Successful delivery rewrites that pair at the requested output path; earlier review evidence remains bound to its original artifact bytes. See [review evidence paths](references/delivery-contract.md#a-new-candidate-at-an-existing-output-path). If that run fails, reports more crossings, or reports the same crossings with more `signals.routesOverSuggestedBends`, restore the copy and finalize it with `--out-dir /review-3`. Do not start a second placement round. Hints about extra bends alone are optional. + +When `layoutReviewRecommendation.action` is `inspect-sequence-width`, follow [Sequence width review](references/delivery-contract.md#sequence-width-review) before handing off a newly authored Sequence. + +Perceptual review is optional for ordinary generation, including a newly positioned Architecture. Use [Optional capture evidence](references/delivery-contract.md#optional-capture-evidence), with `--out-dir /visual-check`, when the user requests visual review, during development audits, or for a concrete route/browser concern. `visualReviewRecommendation` is advisory. Inspect captures before claiming visual quality; qualify any check claim as automated when perceptual review has not occurred. + +Read [Delivery contract](references/delivery-contract.md) for failed gates, standalone commands, provenance/recovery, repeated delivery, exports, or opening. Native diagram recovery follows `deliver` → strict provenance `check` → `browser-check`; captures require strict provenance. Explainer delivery uses validate, deliver and check as described in its authoring guide. + +For workflow viewport overflow, read [Workflow viewport repair](references/authoring-contract.md#workflow-viewport-repair) before the next layout edit. + +When reporting verification details, distinguish artifact checks, browser evidence, captures, and actual perceptual review. For an explicitly requested immediate preview or active desktop loop, see [Optional opening](references/delivery-contract.md#optional-opening). + +## Optional viewer capabilities + +Live motion is enabled by default for every rendered diagram; omit `meta.animation`. The Viewer’s Live/Still button controls playback. + +Read `references/viewer-runtime.md` only when the user explicitly asks for Share Cards, Route/Reach cards, motion, deep links, presentation, search/focus, or another Viewer Runtime feature. + +## Setup and fallback + +No install is required inside the skill package. For setup diagnosis, verify with: + +```bash +node bin/archify.mjs doctor +node bin/archify.mjs demo +``` + +For native diagrams, 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`. Explainers require their renderer and real delivery gates; if those are unavailable, report the limitation and give a text answer when useful, without claiming HTML delivery. + +## Output + +Reply in the user's language, as briefly as the result allows; the diagram carries the content, so a single diagram usually needs one line. + +- Lead with a clickable Markdown link to the HTML: a descriptive title, not the file name, and its absolute path, wrapped in `<...>` when it contains spaces. +- When the user wants to understand a system or codebase rather than draw a described subject, add one or two sentences with the key takeaway. Skip sentences that only restate what the diagram obviously shows. +- State anything that changes how the diagram should be read: the abstraction level or deliberately omitted parts when a reader might expect them, illustrative rather than measured values, missing source evidence, or requested content dropped or merged during repair. +- For several diagrams, report completion once with a link to an existing overview or compact per-diagram links, not a directory path. + +Do not recap commands, repair rounds, hashes, quality profiles, or check lists; they stay in the receipts. If asked whether the diagram was verified, answer in one line, such as “Automated checks passed; not visually reviewed.” Give receipt links and details only for reproducibility or troubleshooting. Disclose requested visual review that could not run, and claim visual inspection only when performed. Keep the required update notice under Update awareness. + +Example: `Created [Checkout request timing](/abs/path/checkout-timing.html). Durations are illustrative, not measured.` + +For an explainer, reply in two or three lines: the conclusion and the page link. Mention gates only when one failed or writing warnings remain; do not paste the draft. + +After an explainer made on request, when no `[archify explainer: ...]` line is in context, end the reply with one optional line offering the proactive mode, once per conversation, for example: “需要的话,以后遇到解释、对比、排查这类问题,我可以主动做成这样的页面,要开启吗?” If the user accepts, add `[archify explainer: proactive] Make an Archify explainer page, unasked, for explanations, comparisons, decisions, diagnoses, reviews and plans that need a table, steps or several sections.` to the global rules file of the current agent (such as `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md` or `~/.config/devin/AGENTS.md`) and name the file; deleting the line turns it off. If the user declines, offer to add `[archify explainer: on-demand]` so the question does not return. diff --git a/ui-plugins/archify-panel/vendor/archify/THIRD_PARTY_NOTICES.md b/ui-plugins/archify-panel/vendor/archify/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..4060ce3d --- /dev/null +++ b/ui-plugins/archify-panel/vendor/archify/THIRD_PARTY_NOTICES.md @@ -0,0 +1,78 @@ +# Third-party notices + +Archify includes optional vector data for third-party brand marks. These marks +are provided only to identify technologies and services in user-authored +diagrams. Their inclusion does not imply sponsorship, endorsement, partnership, +or affiliation with Archify. + +Archify's license and historical MIT grants are described in LICENSE, NOTICE, +and LICENSE-MIT. The software license does not +replace the copyright licenses, trademark policies, or brand guidelines that +apply to third-party marks. Users are responsible for confirming that their +particular use is permitted. + +## Simple Icons + +Most of the built-in vector paths and their metadata were generated from +[Simple Icons 16.28.0](https://github.com/simple-icons/simple-icons/tree/16.28.0). +Simple Icons makes its collection work available under +[CC0 1.0 Universal](https://github.com/simple-icons/simple-icons/blob/16.28.0/LICENSE.md). + +As Simple Icons explains in its +[disclaimer](https://github.com/simple-icons/simple-icons/blob/16.28.0/DISCLAIMER.md), +CC0 for the collection does not mean that every underlying icon is CC0. License +and brand-guideline metadata may be incomplete or change over time. The absence +of an individual license entry is not a grant of permission. + +Archify embeds the selected icons as vector-path data and may render them in a +user-selected color. The following individual licenses were recorded in the +pinned Simple Icons 16.28.0 metadata: + +| Mark | Recorded source | Recorded license | Archify treatment | +|---|---|---|---| +| Angular | [Angular press kit](https://angular.dev/press-kit) | [`CC-BY-4.0`](https://creativecommons.org/licenses/by/4.0/) | Embedded as vector-path data; color may be changed by the authored diagram. | +| Apache Airflow | [Apache logos](https://apache.org/logos) | [`Apache-2.0`](https://www.apache.org/licenses/LICENSE-2.0) | Embedded as vector-path data; Apache trademarks remain subject to the [ASF trademark policy](https://www.apache.org/foundation/marks/). | +| Apache Kafka | [Apache logos](https://apache.org/logos) | [`Apache-2.0`](https://www.apache.org/licenses/LICENSE-2.0) | Embedded as vector-path data; Apache trademarks remain subject to the [ASF trademark policy](https://www.apache.org/foundation/marks/). | +| .NET | [.NET brand repository](https://github.com/dotnet/brand/blob/c7d0f51b8ec59531332d05fb27a5b758a7a3d689/logo/dotnet-logo.svg) | [`CC0-1.0`](https://creativecommons.org/publicdomain/zero/1.0/) | Embedded as vector-path data; color may be changed by the authored diagram. | +| JavaScript | [JS community logo](https://github.com/voodootikigod/logo.js/blob/1544bdeed6d618a6cfe4f0650d04ab8d9cfa76d9/js.svg) | [`MIT`](https://github.com/voodootikigod/logo.js/blob/1544bdeed6d618a6cfe4f0650d04ab8d9cfa76d9/LICENSE) | Embedded as vector-path data; color may be changed by the authored diagram. | +| Jenkins | [Jenkins artwork source](https://get.jenkins.io/art/) | [`CC-BY-SA-3.0`](https://creativecommons.org/licenses/by-sa/3.0/) | Embedded as vector-path data; color may be changed by the authored diagram. Jenkins retains its trademark rights. | +| Rust | [Rust project](https://www.rust-lang.org) | [`CC-BY-SA-4.0`](https://creativecommons.org/licenses/by-sa/4.0/) | Embedded as vector-path data; color may be changed by the authored diagram. See the [Rust media guide](https://www.rust-lang.org/policies/media-guide). | +| Vue.js | [Vue logo source](https://github.com/vuejs/art/blob/a1c78b74569b70a25300925b4eacfefcc143b8f6/logo.svg) | [`CC-BY-NC-SA-4.0`](https://creativecommons.org/licenses/by-nc-sa/4.0/) | Embedded as vector-path data; color may be changed by the authored diagram. The non-commercial and share-alike conditions remain applicable; see the [Vue artwork terms](https://github.com/vuejs/art/blob/a1c78b74569b70a25300925b4eacfefcc143b8f6/README.md). | + +The source, guideline, and known license fields for every packaged mark are +preserved in `renderers/shared/generated-brand-marks.mjs`. + +## OpenAI mark + +The OpenAI vector path is recorded from the +[OpenAI brand guidelines](https://openai.com/brand/), not from Simple Icons. +Use remains subject to those current guidelines and any applicable trademark +rights. Its inclusion does not state or imply endorsement by OpenAI. + +## JetBrains Mono + +Delivered Archify viewer artifacts embed the JetBrains Mono variable font +subsets served by Google Fonts. For characters covered by these subsets, font +selection does not depend on a network request or a locally installed copy. +Uncovered characters (including CJK) still use the system fallback stack; +browser and operating-system rasterization can differ. +JetBrains Mono is maintained at +[github.com/JetBrains/JetBrainsMono](https://github.com/JetBrains/JetBrainsMono) +and is distributed under the SIL Open Font License 1.1. The complete license +text is preserved in `assets/JetBrainsMono-OFL.txt` in the packaged Skill and +in the font CSS carried by standalone HTML and SVG exports. + +## Pele + +The explainer renders Mermaid chart fences with an unmodified subset of +[Pele 1.3.0](https://github.com/obsidianmd/pele), copyright (c) 2026 kepano, +distributed under the MIT License. The subset and its license text are in +`explainer/vendor/pele/` in the packaged Skill. Charts are rendered to static +SVG when a page is built; delivered pages contain no Pele code. + +## No additional rights granted + +Brand names, logos, and trademarks remain the property of their respective +owners. This notice records provenance and known terms; it does not grant rights +that Archify does not hold, and it does not state that every packaged mark has +been cleared for every commercial, promotional, or redistributive use. diff --git a/ui-plugins/archify-panel/vendor/archify/assets/JetBrainsMono-OFL.txt b/ui-plugins/archify-panel/vendor/archify/assets/JetBrainsMono-OFL.txt new file mode 100644 index 00000000..675d198b --- /dev/null +++ b/ui-plugins/archify-panel/vendor/archify/assets/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/ui-plugins/archify-panel/vendor/archify/assets/template.html b/ui-plugins/archify-panel/vendor/archify/assets/template.html new file mode 100644 index 00000000..51da6984 --- /dev/null +++ b/ui-plugins/archify-panel/vendor/archify/assets/template.html @@ -0,0 +1,14768 @@ + + + + + + + + [PROJECT NAME] Architecture Diagram + + + + + + + + +
+ +
+
+
+

[PROJECT NAME] Architecture

+
+

[Subtitle description]

+
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Users + Browser/Mobile + + + + + Auth Provider + OAuth 2.0 + + + + AWS Region: us-west-2 + + + + + CloudFront + CDN + + + + + S3 Buckets + • bucket-one + • bucket-two + • bucket-three + OAI Protected + + + + sg-name :port + + + + + Load Balancer + HTTPS :443 + + + + + API Server + FastAPI :8000 + + + + + Database + PostgreSQL + + + + + Frontend + React + TypeScript + Additional detail + More info + domain.example.com + + + + + + HTTPS + + + + + + + OAI + + + + + TLS + + + + JWT + PKCE + + + Legend + + + Frontend + + + Backend + + + Cloud Service + + + Database + + + Security + + + Auth Flow + + + Security Group + + +

+ + + + + + + + + +
+ + + +
+ + + + diff --git a/ui-plugins/archify-panel/vendor/archify/bin/archify.mjs b/ui-plugins/archify-panel/vendor/archify/bin/archify.mjs new file mode 100755 index 00000000..8b9e5341 --- /dev/null +++ b/ui-plugins/archify-panel/vendor/archify/bin/archify.mjs @@ -0,0 +1,7289 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const skillRoot = path.resolve(__dirname, '..'); + +const TYPES = new Set(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle', 'erd', 'tree', 'class', 'timeline', 'waterfall', 'explainer']); +const DELIVERY_SIDECAR_SUFFIXES = Object.freeze([ + '.delivery.json', + '.delivery-pending.json', + '.delivery-lock.json', +]); +const COMPARE_SIDECAR_SUFFIXES = Object.freeze(['.receipt.json']); +const DELIVERY_DIRECTORY_LOCK = '.archify-delivery-lock.json'; +let sidecarNamespaceComponentKeyRuntime; +let boundedSidecarStem; +let isBoundedSidecarStem; +let sidecarStemFromComponent; +let sidecarStemNeedsBounding; + +async function loadSidecarPathRuntime() { + ({ + boundedSidecarStem, + isBoundedSidecarStem, + sidecarStemFromComponent, + sidecarStemNeedsBounding, + } = await import('../renderers/shared/sidecar-path.mjs')); +} + +function deliverySidecarNamespace(artifactPath) { + let artifact = path.resolve(artifactPath); + try { + artifact = fs.realpathSync.native(artifact); + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + } + if (typeof sidecarNamespaceComponentKeyRuntime !== 'function') { + throw new Error('The sidecar namespace runtime is unavailable.'); + } + const namespace = sidecarNamespaceComponentKeyRuntime( + path.dirname(artifact), + path.basename(artifact), + ); + if (namespace.status !== 'resolved') { + const error = new Error( + `Could not establish a stable sidecar namespace (${namespace.reason?.code || 'unknown'}).`, + ); + error.code = 'ARCHIFY_SIDECAR_NAMESPACE_INDETERMINATE'; + error.sidecarNamespaceReason = namespace.reason; + throw error; + } + return { directory: namespace.directoryPath, ...sidecarStemFromComponent(namespace.componentKey) }; +} + +function deliverySidecarPath(artifactPath, suffix) { + const { directory, stem, options } = deliverySidecarNamespace(artifactPath); + return path.join( + directory, + `${boundedSidecarStem(stem, DELIVERY_SIDECAR_SUFFIXES, options)}${suffix}`, + ); +} + +function deliveryProvenancePath(artifactPath) { + return deliverySidecarPath(artifactPath, '.delivery.json'); +} + +function deliveryPendingPath(artifactPath) { + return deliverySidecarPath(artifactPath, '.delivery-pending.json'); +} + +function rawHeadDeliverySidecarPath(artifactPath, suffix) { + const { directory, stem, options } = deliverySidecarNamespace(artifactPath); + if (options?.force) return undefined; + if (sidecarStemNeedsBounding(stem, DELIVERY_SIDECAR_SUFFIXES) + || !isBoundedSidecarStem(stem)) return undefined; + return path.join(directory, `${stem}${suffix}`); +} + +function rawHeadDeliveryProvenancePath(artifactPath) { + return rawHeadDeliverySidecarPath(artifactPath, '.delivery.json'); +} + +function rawHeadDeliveryPendingPath(artifactPath) { + return rawHeadDeliverySidecarPath(artifactPath, '.delivery-pending.json'); +} + +function legacyExtensionDeliverySidecarPath(artifactPath, suffix) { + let artifact = path.resolve(artifactPath); + try { + artifact = fs.realpathSync.native(artifact); + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + } + const component = path.basename(artifact); + if (!/\.html$/iu.test(component) || component.endsWith('.html')) return undefined; + const legacyComponent = `${component.slice(0, -'.html'.length)}${suffix}`; + if (legacyComponent.length > 255 || Buffer.byteLength(legacyComponent, 'utf8') > 255) { + return undefined; + } + return path.join(path.dirname(artifact), legacyComponent); +} + +function canonicalExistingDeliveryOutput(outputPath) { + const output = path.resolve(outputPath); + try { + return fs.realpathSync.native(output); + } catch (error) { + if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return output; + throw error; + } +} + +function legacyDeliveryLockPath(artifactPath) { + const artifact = path.resolve(artifactPath); + return `${artifact.replace(/\.html?$/i, '')}.delivery-lock.json`; +} + +function legacyDeliveryLockPaths(artifactPaths) { + const paths = []; + for (const artifactPath of artifactPaths) { + const lockPath = legacyDeliveryLockPath(artifactPath); + const name = path.basename(lockPath); + const canExist = process.platform === 'win32' + ? name.length <= 255 + : Buffer.byteLength(name, 'utf8') <= 255; + if (canExist && !paths.includes(lockPath)) paths.push(lockPath); + } + return paths; +} + +function deliveryLockPath(artifactPath) { + // A directory-wide mutex is deliberately conservative. Future 8.3, case, + // and Unicode aliases cannot be assigned a sound canonical filename before + // the artifact exists, so a per-artifact lock can split one delivery into + // multiple owners. Provenance and pending journals remain artifact-specific. + const artifact = path.resolve(artifactPath); + let directory; + try { + directory = path.dirname(fs.realpathSync.native(artifact)); + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + const authoredDirectory = path.dirname(artifact); + try { + directory = fs.realpathSync.native(authoredDirectory); + } catch (directoryError) { + if (directoryError?.code !== 'ENOENT' && directoryError?.code !== 'ENOTDIR') throw directoryError; + directory = authoredDirectory; + } + } + return path.join(directory, DELIVERY_DIRECTORY_LOCK); +} + +function deliveryLockInvalid(lockPath) { + return Object.assign(new Error(`Unrecognized delivery lock at "${lockPath}"; inspect the existing entry before retrying.`), { + deliveryLockCode: 'delivery/lock-invalid', + deliveryLockPath: lockPath, + }); +} + +function regularDeliveryLockExists(lockPath) { + try { + if (!fs.lstatSync(lockPath).isFile()) throw deliveryLockInvalid(lockPath); + return true; + } catch (error) { + if (error.deliveryLockCode) throw error; + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false; + throw deliveryLockInvalid(lockPath); + } +} + +function rejectExistingDeliveryLock(lockPath, output, fileBindingRuntime) { + const { + captureRegularFileBinding, + releaseRegularFileBinding, + } = fileBindingRuntime || {}; + let lock; + let captured; + try { + if (typeof captureRegularFileBinding !== 'function' + || typeof releaseRegularFileBinding !== 'function') { + throw new Error('The regular-file binding runtime is unavailable.'); + } + captured = captureRegularFileBinding(lockPath, { + subject: 'delivery-lock', + expectedLinks: 1, + includeContent: true, + }); + if (captured.status !== 'captured') throw new Error('Not a stable regular lock file.'); + lock = JSON.parse(captured.content.buffer.toString('utf8')); + if (lock?.schemaVersion !== 1 || !Number.isSafeInteger(lock.pid) || lock.pid <= 0 + || typeof lock.receiptId !== 'string' + || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(lock.receiptId)) { + throw new Error('Unrecognized delivery lock.'); + } + } catch { + throw deliveryLockInvalid(lockPath); + } finally { + if (captured?.binding) releaseRegularFileBinding(captured.binding); + } + if (processIsRunning(lock.pid)) { + throw Object.assign(new Error(`Another delivery attempt owns "${output}" through lock "${lockPath}".`), { + deliveryLockCode: 'delivery/concurrent-attempt', + deliveryLockPath: lockPath, + }); + } + throw Object.assign(new Error(`A stale delivery lock remains at "${lockPath}"; recover it serially before retrying.`), { + deliveryLockCode: 'delivery/lock-stale', + deliveryLockPath: lockPath, + deliveryLockOwner: { pid: lock.pid, receiptId: lock.receiptId }, + }); +} + +function processIsRunning(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error.code !== 'ESRCH'; + } +} + +const deliveryOwnershipStates = new WeakMap(); + +function deliveryOwnershipFailure(state, operation, reason, evidence = {}) { + const error = new Error(`Delivery ownership was lost while attempting to ${operation}: ${reason}`); + error.deliveryOwnershipCode = 'delivery/ownership-lost'; + error.deliveryOwnershipDetails = { + operation, + lock: state?.lockPath, + expectedReceiptId: state?.receiptId, + reason, + ...evidence, + }; + if (state && state.phase !== 'released') state.phase = 'lost'; + return error; +} + +function deliveryOwnershipState(ownership, operation) { + const state = deliveryOwnershipStates.get(ownership); + if (!state) throw deliveryOwnershipFailure(undefined, operation, 'the ownership capability is invalid'); + if (state.phase === 'lost' || state.phase === 'released') { + throw deliveryOwnershipFailure(state, operation, `the capability is already ${state.phase}`); + } + return state; +} + +function sameFileIdentity(actual, expected) { + const hasIdentity = (entry) => entry?.ino !== undefined && entry.ino !== 0 && entry.ino !== 0n; + return hasIdentity(actual) + && hasIdentity(expected) + && actual.dev === expected.dev + && actual.ino === expected.ino; +} + +function assertOwnedDeliveryLock(state, ownedLock, operation, { verifyReceipt = true } = {}) { + const { + captureRegularFileBinding, + releaseRegularFileBinding, + } = state?.fileBindingRuntime || {}; + let captured; + try { + if (typeof captureRegularFileBinding !== 'function' + || typeof releaseRegularFileBinding !== 'function') { + throw new Error('The regular-file binding runtime is unavailable.'); + } + captured = captureRegularFileBinding(ownedLock.path, { + subject: 'owned-delivery-lock', + ...(ownedLock.content ? { + expectedSha256: ownedLock.content.sha256, + expectedBytes: ownedLock.content.bytes, + } : {}), + expectedMode: Number(ownedLock.identity.mode & 0o777n), + expectedLinks: 1, + includeContent: verifyReceipt, + }); + } catch (error) { + throw deliveryOwnershipFailure(state, operation, 'an owned lock entry can no longer be inspected', { + lock: ownedLock.path, + ...(error?.code ? { systemCode: error.code } : {}), + }); + } + if (captured.status !== 'captured' + || captured.identity.device !== ownedLock.identity.dev + || captured.identity.inode !== ownedLock.identity.ino) { + if (captured?.binding) releaseRegularFileBinding(captured.binding); + throw deliveryOwnershipFailure(state, operation, 'an owned lock path no longer names the entry created by this attempt', { + lock: ownedLock.path, + ...(captured?.reason ? { lockState: captured.reason } : {}), + }); + } + try { + if (!verifyReceipt) return; + let lock; + try { + lock = JSON.parse(captured.content.buffer.toString('utf8')); + } catch (error) { + throw deliveryOwnershipFailure(state, operation, 'an owned lock receipt can no longer be verified', { + lock: ownedLock.path, + ...(error?.code ? { systemCode: error.code } : {}), + }); + } + if (lock?.schemaVersion !== 1 || lock.receiptId !== state.receiptId || lock.pid !== state.pid) { + throw deliveryOwnershipFailure(state, operation, 'an owned lock receipt no longer matches this attempt', { + lock: ownedLock.path, + ...(typeof lock?.receiptId === 'string' ? { observedReceiptId: lock.receiptId } : {}), + }); + } + } catch (error) { + throw error; + } finally { + releaseRegularFileBinding(captured.binding); + } +} + +function assertDeliveryOwnership(ownership, operation, { allowInitializing = false, pending = 'ignore' } = {}) { + const state = deliveryOwnershipState(ownership, operation); + const verifyReceipt = !allowInitializing || !['acquiring', 'initializing'].includes(state.phase); + for (const ownedLock of state.ownedLocks) { + assertOwnedDeliveryLock(state, ownedLock, operation, { verifyReceipt }); + } + if (pending !== 'ignore') { + const pendingPath = state.pendingPath; + if (pending === 'absent') { + try { + fs.lstatSync(pendingPath); + } catch (error) { + if (error.code === 'ENOENT') return state; + throw deliveryOwnershipFailure(state, operation, 'the finalized delivery journal can no longer be inspected', { + journal: pendingPath, + ...(error?.code ? { systemCode: error.code } : {}), + }); + } + throw deliveryOwnershipFailure(state, operation, 'the finalized delivery journal was recreated', { journal: pendingPath }); + } + const { + captureRegularFileBinding, + releaseRegularFileBinding, + } = state.fileBindingRuntime || {}; + let captured; + try { + if (typeof captureRegularFileBinding !== 'function' + || typeof releaseRegularFileBinding !== 'function') { + throw new Error('The regular-file binding runtime is unavailable.'); + } + captured = captureRegularFileBinding(pendingPath, { + subject: 'owned-delivery-journal', + expectedSha256: state.pendingContent?.sha256, + expectedBytes: state.pendingContent?.bytes, + expectedMode: state.pendingIdentity?.mode, + expectedLinks: 1, + includeContent: pending === 'owned', + }); + } catch (error) { + throw deliveryOwnershipFailure(state, operation, 'the owned delivery journal can no longer be inspected', { + journal: pendingPath, + ...(error?.code ? { systemCode: error.code } : {}), + }); + } + if (captured.status !== 'captured' + || !state.pendingIdentity + || captured.identity.device !== state.pendingIdentity.dev + || captured.identity.inode !== state.pendingIdentity.ino) { + if (captured?.binding) releaseRegularFileBinding(captured.binding); + throw deliveryOwnershipFailure(state, operation, 'the delivery journal no longer names the entry created by this attempt', { + journal: pendingPath, + ...(captured?.reason ? { journalState: captured.reason } : {}), + }); + } + try { + if (pending === 'owned') { + let journal; + try { + journal = JSON.parse(captured.content.buffer.toString('utf8')); + } catch { + throw deliveryOwnershipFailure(state, operation, 'the delivery journal receipt is malformed', { + journal: pendingPath, + }); + } + if (journal?.receiptId !== state.receiptId) { + throw deliveryOwnershipFailure(state, operation, 'the delivery journal receipt belongs to another attempt', { + journal: pendingPath, + ...(typeof journal?.receiptId === 'string' ? { observedReceiptId: journal.receiptId } : {}), + }); + } + } + } finally { + releaseRegularFileBinding(captured.binding); + } + } + return state; +} + +function quarantineRemoveOwnedDeliveryEntry(state, { + filePath, + identity, + content, + subject, + operation, + evidenceKey, +}) { + const { + captureRegularFileBinding, + quarantineRemoveRegularFileBinding, + releaseRegularFileBinding, + } = state?.fileBindingRuntime || {}; + let captured; + try { + if (typeof captureRegularFileBinding !== 'function' + || typeof quarantineRemoveRegularFileBinding !== 'function' + || typeof releaseRegularFileBinding !== 'function') { + throw new Error('The regular-file binding runtime is unavailable.'); + } + captured = captureRegularFileBinding(filePath, { + subject, + ...(content ? { + expectedSha256: content.sha256, + expectedBytes: content.bytes, + } : {}), + expectedMode: typeof identity?.mode === 'bigint' + ? Number(identity.mode & 0o777n) + : identity?.mode, + expectedIdentity: { + device: identity?.dev, + inode: identity?.ino, + }, + expectedLinks: 1, + }); + } catch (error) { + throw deliveryOwnershipFailure(state, operation, `the owned ${subject} can no longer be captured for removal`, { + [evidenceKey]: filePath, + ...(error?.code ? { systemCode: error.code } : {}), + }); + } + if (captured.status !== 'captured') { + throw deliveryOwnershipFailure(state, operation, `the owned ${subject} changed before removal`, { + [evidenceKey]: filePath, + entryState: captured.reason, + }); + } + try { + const removed = quarantineRemoveRegularFileBinding(captured.binding, filePath, { + subject, + expectedLinks: 1, + }); + if (removed.status === 'removed') return; + const removalCode = removed.reason?.code; + if (removed.status === 'unknown' + && ['removal-quarantine-create-failed', `${subject}-quarantine-move-failed`] + .includes(removalCode)) { + const detail = removed.reason?.message ? `: ${removed.reason.message}` : ''; + const error = new Error(`The owned ${subject} could not be moved into its private removal quarantine (${removalCode})${detail}.`); + if (removed.reason?.systemCode) error.code = removed.reason.systemCode; + if (subject === 'delivery-lock') { + error.deliveryOwnershipCode = 'delivery/lock-release'; + error.deliveryOwnershipDetails = { + operation, + lock: filePath, + reason: error.message, + entryState: removed.reason, + }; + } else { + error.deliveryJournalRemovalState = removed; + } + throw error; + } + throw deliveryOwnershipFailure( + state, + operation, + removed.status === 'recovery-required' + ? `a replacement ${subject} could not be restored from quarantine` + : `the public ${subject} changed during removal and was preserved`, + { + [evidenceKey]: filePath, + entryState: removed.reason, + ...(removed.status === 'recovery-required' ? { + recoveryRequired: true, + recoveryDirectory: removed.recoveryDirectory, + recoveryFiles: [{ + label: `preserved replacement ${subject}`, + path: removed.recoveryFile, + target: filePath, + }], + } : {}), + }, + ); + } finally { + releaseRegularFileBinding(captured.binding); + } +} + +function releaseDeliveryOwnership(ownership, { allowInitializing = false } = {}) { + const current = deliveryOwnershipState(ownership, 'release the delivery lock'); + const pending = current.phase === 'finalized' + ? 'absent' + : current.pendingIdentity + ? 'identity' + : 'ignore'; + const state = assertDeliveryOwnership(ownership, 'release the delivery lock', { allowInitializing, pending }); + const verifyReceipt = !allowInitializing || !['acquiring', 'initializing'].includes(state.phase); + for (const ownedLock of state.ownedLocks) { + assertOwnedDeliveryLock(state, ownedLock, 'release the delivery lock', { verifyReceipt }); + quarantineRemoveOwnedDeliveryEntry(state, { + filePath: ownedLock.path, + identity: ownedLock.identity, + content: ownedLock.content, + subject: 'delivery-lock', + operation: 'release the delivery lock', + evidenceKey: 'lock', + }); + } + state.phase = 'released'; +} + +function acquireDeliveryLock( + output, + receiptId, + inputPath, + pathsAlias, + recordInitializationFailure, + legacyOutputPaths = [], + fileBindingRuntime, +) { + const stableOutput = canonicalExistingDeliveryOutput(output); + const lockPath = deliveryLockPath(stableOutput); + const comparableLockPath = (candidate) => { + const absolute = path.resolve(candidate); + try { + return path.join(fs.realpathSync.native(path.dirname(absolute)), path.basename(absolute)); + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + return absolute; + } + }; + const lockPathsAlias = (left, right) => { + const comparableLeft = comparableLockPath(left); + const comparableRight = comparableLockPath(right); + if (!pathsAlias(path.dirname(comparableLeft), path.dirname(comparableRight))) return false; + return pathsAlias(comparableLeft, comparableRight); + }; + const legacyFencePathsAlias = (left, right) => { + const comparableLeft = comparableLockPath(left); + const comparableRight = comparableLockPath(right); + if (comparableLeft === comparableRight) return true; + try { + return lockPathsAlias(comparableLeft, comparableRight); + } catch { + // An indeterminate future-name relation is not evidence of aliasing. + // Keep both candidates; an EEXIST below is accepted only when dev/ino + // proves that it names a lock already created by this attempt. + return false; + } + }; + const legacyLockPaths = []; + for (const candidate of legacyDeliveryLockPaths([ + ...legacyOutputPaths.filter(Boolean), + output, + stableOutput, + ])) { + if (!legacyLockPaths.some((existing) => legacyFencePathsAlias(candidate, existing))) { + legacyLockPaths.push(candidate); + } + } + const pendingPath = deliveryPendingPath(stableOutput); + const provenancePath = deliveryProvenancePath(stableOutput); + const rawHeadPendingPath = rawHeadDeliveryPendingPath(stableOutput); + const existingLegacyLocks = legacyLockPaths.filter((candidate) => regularDeliveryLockExists(candidate)); + regularDeliveryLockExists(lockPath); + const conflictingLockPath = [lockPath, ...legacyLockPaths] + .find((candidate) => lockPathsAlias(candidate, inputPath)); + if (conflictingLockPath) { + throw Object.assign(new Error('Delivery lock path aliases the input specification; choose another output path.'), { + deliveryLockCode: 'delivery/lock-path-conflict', + deliveryLockPath: conflictingLockPath, + }); + } + if (existingLegacyLocks.length) { + rejectExistingDeliveryLock(existingLegacyLocks[0], output, fileBindingRuntime); + } + const openedLocks = []; + const ownership = Object.freeze({}); + deliveryOwnershipStates.set(ownership, { + output: stableOutput, + lockPath, + pendingPath, + provenancePath, + receiptId, + pid: process.pid, + ownedLocks: openedLocks, + fileBindingRuntime, + phase: 'acquiring', + }); + let collisionPath; + try { + const openOwnedLock = (ownedLockPath, { allowOwnedAlias = false } = {}) => { + let descriptor; + try { + descriptor = fs.openSync(ownedLockPath, 'wx', 0o600); + } catch (error) { + if (error.code === 'EEXIST' && allowOwnedAlias) { + try { + const existingEntry = fs.lstatSync(ownedLockPath, { bigint: true }); + if (existingEntry.isFile() + && openedLocks.some((ownedLock) => sameFileIdentity(existingEntry, ownedLock.identity))) { + return undefined; + } + } catch { + // Preserve the original EEXIST classification below. + } + } + if (error.code === 'EEXIST') collisionPath = ownedLockPath; + throw error; + } + let handleIdentity; + let handleIdentityError; + try { + handleIdentity = fs.fstatSync(descriptor, { bigint: true }); + } catch (error) { + // A transient first inspection failure must not strand the public + // lock. Bind ownership from the still-open handle before cleanup. + try { + handleIdentity = fs.fstatSync(descriptor, { bigint: true }); + } catch { + // Both handle metadata reads failed. Stamp the still-open inode with + // this attempt's unpredictable receipt before consulting the public + // path. A replacement claimant cannot be mistaken for our empty + // lock, while a successful content-bound capture gives cleanup the + // identity it needs after the original descriptor is closed. + const recoveryReceipt = `${JSON.stringify({ schemaVersion: 1, receiptId, pid: process.pid })}\n`; + const recoveryContent = artifactIdentity(Buffer.from(recoveryReceipt)); + let captured; + try { + fs.writeFileSync(descriptor, recoveryReceipt); + captured = fileBindingRuntime?.captureRegularFileBinding?.(ownedLockPath, { + subject: 'owned-delivery-lock', + expectedSha256: recoveryContent.sha256, + expectedBytes: recoveryContent.bytes, + expectedLinks: 1, + }); + } catch {} + if (captured?.status === 'captured') { + fileBindingRuntime.releaseRegularFileBinding(captured.binding); + openedLocks.push({ + path: ownedLockPath, + identity: { + dev: captured.identity.device, + ino: captured.identity.inode, + mode: BigInt(captured.mode), + }, + content: recoveryContent, + descriptor, + }); + } else { + try { + fs.closeSync(descriptor); + } catch (closeError) { + error.lockCleanupError = closeError.message; + } + } + throw error; + } + handleIdentityError = error; + } + const ownedLock = { + path: ownedLockPath, + identity: handleIdentity, + descriptor, + }; + openedLocks.push(ownedLock); + const pathIdentity = fs.lstatSync(ownedLockPath, { bigint: true }); + if (!handleIdentity.isFile() + || handleIdentity.ino === 0n + || !pathIdentity.isFile() + || pathIdentity.isSymbolicLink() + || pathIdentity.ino === 0n + || !sameFileIdentity(pathIdentity, handleIdentity)) { + throw new Error('Delivery lock path identity could not be verified safely.'); + } + if (handleIdentityError) throw handleIdentityError; + return ownedLock; + }; + + openOwnedLock(lockPath); + for (const legacyLockPath of legacyLockPaths) { + openOwnedLock(legacyLockPath, { allowOwnedAlias: true }); + } + const state = deliveryOwnershipStates.get(ownership); + if (rawHeadPendingPath && pathEntryExists(rawHeadPendingPath)) { + throw Object.assign(new Error(`A pre-namespace delivery journal remains at "${rawHeadPendingPath}"; recover it before retrying.`), { + deliveryLockCode: 'delivery/legacy-pending', + deliveryLockPath: rawHeadPendingPath, + }); + } + state.phase = 'initializing'; + const lockReceipt = `${JSON.stringify({ schemaVersion: 1, receiptId, pid: process.pid })}\n`; + const lockContent = artifactIdentity(Buffer.from(lockReceipt)); + for (const ownedLock of openedLocks) { + fs.writeFileSync(ownedLock.descriptor, lockReceipt); + ownedLock.content = lockContent; + } + for (const ownedLock of openedLocks) { + fs.closeSync(ownedLock.descriptor); + ownedLock.descriptor = undefined; + } + state.phase = 'owned'; + return ownership; + } catch (error) { + for (const ownedLock of openedLocks) { + if (ownedLock.descriptor === undefined) continue; + try { + fs.closeSync(ownedLock.descriptor); + ownedLock.descriptor = undefined; + } catch (closeError) { + error.lockCleanupError = closeError.message; + } + } + const state = deliveryOwnershipStates.get(ownership); + let preserveOwnership = false; + try { + if (state.phase === 'initializing') { + assertDeliveryOwnership(ownership, 'record a lock initialization failure', { allowInitializing: true }); + error.deliveryFailureRecord = recordInitializationFailure?.(error, ownership); + if (error.deliveryFailureRecord?.journalRecovery) { + error.deliveryJournalRecovery = error.deliveryFailureRecord.journalRecovery.deliveryJournalRecovery; + preserveOwnership = true; + } + if (error.deliveryFailureRecord?.provenanceRecovery) { + error.deliveryProvenanceRecovery = error.deliveryFailureRecord.provenanceRecovery.deliveryProvenanceRecovery; + preserveOwnership = true; + } + } + if (!preserveOwnership) { + releaseDeliveryOwnership(ownership, { allowInitializing: true }); + } + } catch (cleanupError) { + if (cleanupError.deliveryOwnershipCode) { + cleanupError.deliveryOwnershipDetails = { + ...(cleanupError.deliveryOwnershipDetails || {}), + initializationError: error.message, + ...(error?.code ? { initializationSystemCode: error.code } : {}), + }; + if (cleanupError.deliveryOwnershipCode === 'delivery/lock-release' && error.deliveryFailureRecord) { + cleanupError.deliveryFailureRecord = error.deliveryFailureRecord; + } + throw cleanupError; + } + error.lockCleanupError = cleanupError.message; + } + if (collisionPath) rejectExistingDeliveryLock(collisionPath, output, fileBindingRuntime); + throw error; + } +} + +function pathEntryExists(file) { + try { + fs.lstatSync(file); + return true; + } catch (error) { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false; + throw error; + } +} + +function captureOwnedStagingFile(registry, filePath, fileBindingRuntime, { + subject = 'private-staging-entry', + content, + expectedLinks = 1, +} = {}) { + const { captureRegularFileBinding } = fileBindingRuntime || {}; + if (typeof captureRegularFileBinding !== 'function') { + throw new Error('The regular-file binding runtime is unavailable.'); + } + let captured = captureRegularFileBinding(filePath, { + subject, + ...(content ? { + expectedSha256: content.sha256, + expectedBytes: content.bytes, + } : {}), + expectedLinks, + }); + if (captured.status === 'unsupported' + && String(captured.reason?.code || '').endsWith('-hardlinked') + && content) { + const observedLinks = Number(captured.reason?.links); + if (Number.isSafeInteger(observedLinks) && observedLinks > expectedLinks) { + captured = captureRegularFileBinding(filePath, { + subject, + expectedSha256: content.sha256, + expectedBytes: content.bytes, + expectedLinks: observedLinks, + }); + expectedLinks = observedLinks; + } + } + if (captured.status !== 'captured') { + const error = new Error(`Could not bind the owned staging entry "${filePath}" (${captured.reason?.code || 'unknown state'}).`); + error.stagingCapture = captured; + throw error; + } + registry.push({ filePath, binding: captured.binding, subject, expectedLinks }); + return captured; +} + +function cleanupOwnedStagingDirectory(directory, identity, registry, fileBindingRuntime) { + const { + quarantineRemoveRegularFileBinding, + releaseRegularFileBinding, + } = fileBindingRuntime || {}; + if (typeof quarantineRemoveRegularFileBinding !== 'function' + || typeof releaseRegularFileBinding !== 'function') { + throw new Error('The regular-file binding runtime is unavailable.'); + } + const failures = []; + for (const entry of [...registry].reverse()) { + try { + const removed = quarantineRemoveRegularFileBinding(entry.binding, entry.filePath, { + subject: entry.subject, + expectedLinks: entry.expectedLinks, + }); + // A missing path means the transaction already moved or finalized this + // owned inode. Every other non-removal result is deliberately preserved: + // it may be a concurrent claimant rather than an entry we created. + if (removed.status !== 'removed' + && !['ENOENT', 'ENOTDIR'].includes(removed.reason?.systemCode)) { + failures.push(`${entry.filePath}: ${removed.reason?.code || removed.status}`); + } + } catch (error) { + failures.push(`${entry.filePath}: ${error.message}`); + } finally { + const released = releaseRegularFileBinding(entry.binding); + if (released.status !== 'released') { + failures.push(`${entry.filePath}: ${released.reason?.code || released.status}`); + } + } + } + registry.length = 0; + try { + if (!removeOwnedEmptyStagingDirectory(directory, identity, { throwOnFailure: true })) { + failures.push(`${directory}: staging directory was retained because its identity changed`); + } + } catch (error) { + failures.push(`${directory}: ${error.message}`); + } + if (failures.length) throw new Error(failures.join('; ')); +} + +function createOwnedEmptyStagingDirectory(prefix) { + // Node's Windows mkdtemp binding does not consistently promote an ordinary + // UNC prefix past MAX_PATH. Use the equivalent namespaced spelling for the + // syscall while preserving the same physical directory identity. + const directory = fs.mkdtempSync(path.toNamespacedPath(prefix)); + const metadata = fs.lstatSync(directory, { bigint: true }); + if (!metadata.isDirectory() || metadata.isSymbolicLink() || metadata.ino === 0n) { + throw new Error(`Private staging directory identity is unavailable: ${directory}`); + } + return { + directory, + identity: { device: metadata.dev, inode: metadata.ino }, + }; +} + +function createStagingRetirementQuarantine(parent) { + for (let attempt = 0; attempt < 8; attempt += 1) { + const directory = path.join(parent, `.archify-staging-remove-${randomUUID()}`); + try { + fs.mkdirSync(directory, { mode: 0o700 }); + return directory; + } catch (error) { + if (error?.code !== 'EEXIST') return undefined; + } + } + return undefined; +} + +const stagingRemovalSignal = new Int32Array(new SharedArrayBuffer(4)); +const stagingRemovalAttempts = 10; + +function removeEmptyStagingDirectoryWithRetry(directory) { + let failure; + for (let attempt = 0; attempt < stagingRemovalAttempts; attempt += 1) { + try { + fs.rmdirSync(directory); + return; + } catch (error) { + failure = error; + // SMB can acknowledge the last file removal before the directory view + // catches up. Retrying an empty-directory removal is claimant-safe: any + // real entry keeps returning ENOTEMPTY and is never removed recursively. + if (error?.code !== 'ENOTEMPTY' || attempt === stagingRemovalAttempts - 1) break; + Atomics.wait( + stagingRemovalSignal, + 0, + 0, + Math.min(5 * (2 ** attempt), 250), + ); + } + } + throw failure; +} + +function stagingDirectoryAppearsEmpty(directory) { + for (let attempt = 0; attempt < stagingRemovalAttempts; attempt += 1) { + try { + if (fs.readdirSync(directory).length === 0) return true; + } catch { + return false; + } + if (attempt === stagingRemovalAttempts - 1) break; + Atomics.wait( + stagingRemovalSignal, + 0, + 0, + Math.min(5 * (2 ** attempt), 250), + ); + } + return false; +} + +function removeOwnedEmptyStagingDirectory(directory, identity, { throwOnFailure = false } = {}) { + let current; + try { + current = fs.lstatSync(directory, { bigint: true }); + } catch (error) { + if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return false; + throw error; + } + if (!current.isDirectory() + || current.isSymbolicLink() + || current.ino === 0n + || current.dev !== identity.device + || current.ino !== identity.inode) return false; + if (!stagingDirectoryAppearsEmpty(directory)) return false; + const quarantine = createStagingRetirementQuarantine(path.dirname(directory)); + if (!quarantine) return false; + const movedPath = path.join(quarantine, path.basename(directory)); + try { + fs.renameSync(directory, movedPath); + } catch (error) { + if (throwOnFailure) throw error; + return false; + } + let moved; + try { + moved = fs.lstatSync(movedPath, { bigint: true }); + } catch { + return false; + } + if (!moved.isDirectory() + || moved.isSymbolicLink() + || moved.ino === 0n + || moved.dev !== identity.device + || moved.ino !== identity.inode) { + // A claimant won the public move boundary. Preserve it under the private + // quarantine name; directories have no portable no-clobber restore link. + return false; + } + try { + // The unpredictable 0700 quarantine closes the public replacement race. + // Never recurse: unexpected contents remain available for recovery. + removeEmptyStagingDirectoryWithRetry(movedPath); + removeEmptyStagingDirectoryWithRetry(quarantine); + return true; + } catch (error) { + if (throwOnFailure) throw error; + return false; + } +} + +function releaseOwnedStagingBindings(registry, fileBindingRuntime) { + const { releaseRegularFileBinding } = fileBindingRuntime || {}; + if (typeof releaseRegularFileBinding !== 'function') return; + for (const entry of registry) releaseRegularFileBinding(entry.binding); + registry.length = 0; +} + +function artifactIdentity(artifact) { + return { sha256: createHash('sha256').update(artifact).digest('hex'), bytes: artifact.byteLength }; +} + +function beginDeliveryAttempt({ ownership, input, pathsAlias, fileBindingRuntime }) { + const state = assertDeliveryOwnership(ownership, 'begin the delivery journal', { allowInitializing: true }); + const { + output, pendingPath: journal, provenancePath, receiptId, + } = state; + for (const protectedPath of [input, output, provenancePath].filter(Boolean)) { + if (pathsAlias(journal, protectedPath)) throw new Error('Delivery journal aliases an input or output.'); + } + const published = writeDeliveryProvenance(journal, { + schemaVersion: 1, command: 'deliver', status: 'pending', receiptId, + input, output: path.resolve(output), + }, { + beforeReplace: () => assertDeliveryOwnership(ownership, 'create the delivery journal', { allowInitializing: true }), + fileBindingRuntime, + }); + state.pendingIdentity = { + dev: published.identity.device, + ino: published.identity.inode, + mode: published.identity.mode, + }; + state.pendingContent = published.content; + assertDeliveryOwnership(ownership, 'finish creating the delivery journal', { allowInitializing: true }); +} + +function writeDeliveryProvenance(file, value, { beforeReplace, fileBindingRuntime } = {}) { + const { + backupPublicRegularFileBinding, + captureRegularFileBinding, + quarantineRemoveRegularFileBinding, + verifyRegularFileBinding, + releaseRegularFileBinding, + } = fileBindingRuntime || {}; + if (typeof captureRegularFileBinding !== 'function' + || typeof backupPublicRegularFileBinding !== 'function' + || typeof quarantineRemoveRegularFileBinding !== 'function' + || typeof verifyRegularFileBinding !== 'function' + || typeof releaseRegularFileBinding !== 'function') { + const error = new Error('The regular-file binding runtime is unavailable.'); + error.deliveryJournalState = { + status: 'unknown', + reason: { code: 'atomic-output-runtime-unavailable' }, + }; + throw error; + } + const staging = createOwnedEmptyStagingDirectory( + path.join(path.dirname(file), '.archify-provenance-'), + ); + const stagingDirectory = staging.directory; + const temporary = path.join(stagingDirectory, path.basename(file)); + const backup = path.join(stagingDirectory, '.previous-journal'); + const serialized = Buffer.from(`${JSON.stringify(value, null, 2)}\n`); + const expectedContent = artifactIdentity(serialized); + let candidateCapture; + let previousCapture; + let candidatePresent = false; + let backupPresent = false; + let backupVerified = false; + let published = false; + let retainStaging = false; + const bindingError = (result, action) => { + const error = new Error(`Could not ${action} delivery journal "${file}" safely (${result.reason?.code || 'unknown target state'}).`); + error.deliveryJournalState = result; + return error; + }; + try { + fs.writeFileSync(temporary, serialized, { flag: 'wx' }); + candidatePresent = true; + candidateCapture = captureRegularFileBinding(temporary, { + subject: 'delivery-journal-candidate', + expectedSha256: expectedContent.sha256, + expectedBytes: expectedContent.bytes, + expectedLinks: 1, + }); + if (candidateCapture.status !== 'captured') throw bindingError(candidateCapture, 'prepare the'); + + let existing = false; + try { + fs.lstatSync(file); + existing = true; + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + } + if (existing) { + previousCapture = captureRegularFileBinding(file, { + subject: 'previous-delivery-journal', + expectedLinks: 1, + }); + if (previousCapture.status !== 'captured') { + throw bindingError(previousCapture, 'snapshot the existing'); + } + } + beforeReplace?.(); + if (previousCapture) { + const moved = backupPublicRegularFileBinding(previousCapture.binding, file, backup, { + subject: 'previous-delivery-journal', + }); + backupPresent = moved.backupCreated === true; + backupVerified = moved.backupVerified === true; + if (moved.status !== 'backed-up') throw bindingError(moved, 'back up the existing'); + } + + const ready = verifyRegularFileBinding(candidateCapture.binding, { + filePath: temporary, + expectedLinks: 1, + }); + if (ready.status !== 'match') throw bindingError(ready, 'verify the staged'); + try { + fs.linkSync(temporary, file); + } catch (error) { + if (error?.code === 'EEXIST') { + throw bindingError({ + status: 'different', + reason: { code: 'target-claimed-during-publish', phase: 'journal-publish' }, + }, 'publish the'); + } + throw error; + } + published = true; + for (const target of [temporary, file]) { + const linked = verifyRegularFileBinding(candidateCapture.binding, { + filePath: target, + expectedLinks: 2, + }); + if (linked.status !== 'match') throw bindingError(linked, 'verify the published'); + } + const retiredCandidate = quarantineRemoveRegularFileBinding( + candidateCapture.binding, + temporary, + { subject: 'delivery-journal-candidate', expectedLinks: 2 }, + ); + if (retiredCandidate.status !== 'removed') { + retainStaging = true; + throw bindingError(retiredCandidate, 'retire the staged'); + } + candidatePresent = false; + const finalized = verifyRegularFileBinding(candidateCapture.binding, { + filePath: file, + expectedLinks: 1, + }); + if (finalized.status !== 'match') throw bindingError(finalized, 'finalize the'); + if (backupPresent) { + const previous = verifyRegularFileBinding(previousCapture.binding, { + filePath: backup, + expectedLinks: 1, + }); + if (previous.status !== 'match') throw bindingError(previous, 'verify the previous'); + const retiredBackup = quarantineRemoveRegularFileBinding( + previousCapture.binding, + backup, + { subject: 'previous-delivery-journal', expectedLinks: 1 }, + ); + if (retiredBackup.status !== 'removed') { + retainStaging = true; + throw bindingError(retiredBackup, 'retire the previous'); + } + backupPresent = false; + } + return { + identity: candidateCapture.identity, + content: candidateCapture.content, + }; + } catch (cause) { + const rollbackErrors = []; + const publicRecoveryFiles = []; + if (cause.deliveryJournalState?.status === 'recovery-required' + && cause.deliveryJournalState.recoveryFile) { + publicRecoveryFiles.push({ + label: 'preserved journal replacement', + path: cause.deliveryJournalState.recoveryFile, + target: file, + }); + } + if (published) { + try { + const removed = quarantineRemoveRegularFileBinding(candidateCapture.binding, file, { + subject: 'published-delivery-journal', + expectedLinks: candidatePresent ? 2 : 1, + }); + if (removed.status === 'removed') { + published = false; + } else { + rollbackErrors.push(`the published journal could not be removed safely (${removed.reason?.code || 'unknown target state'})`); + if (removed.status === 'recovery-required') { + publicRecoveryFiles.push({ + label: 'preserved journal replacement', + path: removed.recoveryFile, + target: file, + }); + } + } + } catch (error) { + if (error?.code !== 'ENOENT') rollbackErrors.push(`published journal cleanup failed (${error.message})`); + } + } + if (candidatePresent) { + try { + const current = verifyRegularFileBinding(candidateCapture.binding, { + filePath: temporary, + expectedLinks: published ? 2 : 1, + }); + if (current.status === 'match') { + const retiredCandidate = quarantineRemoveRegularFileBinding( + candidateCapture.binding, + temporary, + { + subject: 'delivery-journal-candidate', + expectedLinks: published ? 2 : 1, + }, + ); + if (retiredCandidate.status !== 'removed') { + throw bindingError(retiredCandidate, 'retire the staged'); + } + candidatePresent = false; + } else { + rollbackErrors.push('the staged journal candidate was replaced before cleanup'); + retainStaging = true; + } + } catch (error) { + if (error?.code !== 'ENOENT') rollbackErrors.push(`staged journal cleanup failed (${error.message})`); + } + } + if (backupPresent) { + try { + if (!backupVerified) { + throw new Error('the backup entry was never verified as the previous journal'); + } + if (pathEntryExists(file)) throw new Error('a new journal claimant prevents restoring the previous journal'); + const previous = verifyRegularFileBinding(previousCapture.binding, { + filePath: backup, + expectedLinks: 1, + }); + if (previous.status !== 'match') { + backupVerified = false; + throw bindingError(previous, 'verify the previous'); + } + try { + fs.linkSync(backup, file); + } catch (error) { + if (error?.code === 'EEXIST') { + throw new Error('a new journal claimant prevents restoring the previous journal'); + } + throw error; + } + for (const target of [backup, file]) { + const restored = verifyRegularFileBinding(previousCapture.binding, { + filePath: target, + expectedLinks: 2, + }); + if (restored.status !== 'match') throw bindingError(restored, 'verify the restored'); + } + const retiredBackup = quarantineRemoveRegularFileBinding( + previousCapture.binding, + backup, + { subject: 'previous-delivery-journal', expectedLinks: 2 }, + ); + if (retiredBackup.status !== 'removed') { + throw bindingError(retiredBackup, 'retire the restored backup'); + } + backupPresent = false; + const restored = verifyRegularFileBinding(previousCapture.binding, { + filePath: file, + expectedLinks: 1, + }); + if (restored.status !== 'match') throw bindingError(restored, 'finalize the restored'); + } catch (error) { + rollbackErrors.push(`previous journal restore failed (${error.message})`); + retainStaging = backupPresent || candidatePresent; + } + } + const claimantConflict = cause.deliveryJournalState?.reason?.code === 'target-claimed-during-publish'; + if (rollbackErrors.length || claimantConflict) { + cause.deliveryJournalRecovery = { + recoveryRequired: true, + journal: file, + ...(claimantConflict ? { claimantConflict: true } : {}), + rollbackErrors, + ...(retainStaging ? { + recoveryDirectory: stagingDirectory, + } : publicRecoveryFiles.length ? { + recoveryDirectory: path.dirname(publicRecoveryFiles[0].path), + } : {}), + ...(backupPresent || (candidatePresent && retainStaging) || publicRecoveryFiles.length ? { + recoveryFiles: [ + ...(backupPresent ? [{ + label: backupVerified + ? 'previous delivery journal' + : 'unverified preserved journal backup entry', + path: backup, + target: file, + verifiedPrevious: backupVerified, + }] : []), + ...(candidatePresent && retainStaging ? [{ label: 'preserved staged journal entry', path: temporary }] : []), + ...publicRecoveryFiles, + ], + } : {}), + }; + } + throw cause; + } finally { + if (candidateCapture?.binding) releaseRegularFileBinding(candidateCapture.binding); + if (previousCapture?.binding) releaseRegularFileBinding(previousCapture.binding); + if (!retainStaging) removeOwnedEmptyStagingDirectory(stagingDirectory, staging.identity); + } +} + +function writeCapturedDeliveryProvenance(file, value, { + beforeCommit, + capture, + verifyAtomicOutput, + fileBindingRuntime, +} = {}) { + const { + backupPublicRegularFileBinding, + captureRegularFileBinding, + quarantineRemoveRegularFileBinding, + verifyRegularFileBinding, + releaseRegularFileBinding, + } = fileBindingRuntime || {}; + if (!capture + || typeof verifyAtomicOutput !== 'function' + || typeof backupPublicRegularFileBinding !== 'function' + || typeof captureRegularFileBinding !== 'function' + || typeof quarantineRemoveRegularFileBinding !== 'function' + || typeof verifyRegularFileBinding !== 'function' + || typeof releaseRegularFileBinding !== 'function') { + throw deliveryTargetStateError(file, 'delivery provenance', { + status: 'unknown', + reason: { code: 'atomic-output-runtime-unavailable' }, + }); + } + const staging = createOwnedEmptyStagingDirectory( + path.join(path.dirname(file), '.archify-provenance-'), + ); + const stagingDirectory = staging.directory; + const candidate = path.join(stagingDirectory, path.basename(file)); + const backup = path.join(stagingDirectory, '.previous-provenance'); + let candidateIdentity; + let candidateBinding; + let previousBinding; + let candidatePresent = false; + let backupPresent = false; + let backupVerified = false; + let published = false; + let retainStaging = false; + try { + const serialized = Buffer.from(`${JSON.stringify(value, null, 2)}\n`); + const expectedContent = artifactIdentity(serialized); + fs.writeFileSync(candidate, serialized, { flag: 'wx' }); + candidatePresent = true; + if (capture.mode !== null) fs.chmodSync(candidate, capture.mode); + const candidateCapture = captureRegularFileBinding(candidate, { + subject: 'delivery-provenance-candidate', + expectedSha256: expectedContent.sha256, + expectedBytes: expectedContent.bytes, + ...(capture.mode === null ? {} : { expectedMode: capture.mode }), + expectedLinks: 1, + }); + if (candidateCapture.status !== 'captured') { + throw deliveryTargetStateError(file, 'delivery provenance', candidateCapture); + } + candidateBinding = candidateCapture.binding; + candidateIdentity = { + dev: candidateCapture.identity.device, + ino: candidateCapture.identity.inode, + }; + beforeCommit?.(); + const verification = verifyAtomicOutput(capture.snapshot); + if (verification.status !== 'match') { + throw deliveryTargetStateError(file, 'delivery provenance', verification); + } + if (capture.snapshot.target.kind === 'file') { + const expected = capture.snapshot.target; + const previous = captureRegularFileBinding(capture.commitPath, { + subject: 'previous-delivery-provenance', + expectedIdentity: { device: expected.device, inode: expected.inode }, + expectedMode: expected.mode, + expectedLinks: 1, + }); + if (previous.status !== 'captured') { + throw deliveryTargetStateError(file, 'delivery provenance', previous); + } + previousBinding = previous.binding; + const moved = backupPublicRegularFileBinding( + previousBinding, + capture.commitPath, + backup, + { subject: 'previous-delivery-provenance' }, + ); + backupPresent = moved.backupCreated === true; + backupVerified = moved.backupVerified === true; + if (moved.status !== 'backed-up') { + throw deliveryTargetStateError(file, 'delivery provenance', moved); + } + } + const currentCandidate = verifyRegularFileBinding(candidateBinding, { + filePath: candidate, + expectedLinks: 1, + }); + if (currentCandidate.status !== 'match') { + throw deliveryTargetStateError(file, 'delivery provenance', currentCandidate); + } + try { + fs.linkSync(candidate, capture.commitPath); + } catch (error) { + if (error?.code === 'EEXIST') { + throw deliveryTargetStateError(file, 'delivery provenance', { + status: 'different', + reason: { code: 'target-claimed-during-publish' }, + }); + } + throw error; + } + published = true; + for (const target of [candidate, capture.commitPath]) { + const linked = verifyRegularFileBinding(candidateBinding, { + filePath: target, + expectedLinks: 2, + }); + if (linked.status !== 'match') { + throw deliveryTargetStateError(file, 'delivery provenance', linked); + } + } + const retiredCandidate = quarantineRemoveRegularFileBinding( + candidateBinding, + candidate, + { subject: 'delivery-provenance-candidate', expectedLinks: 2 }, + ); + if (retiredCandidate.status !== 'removed') { + retainStaging = true; + throw deliveryTargetStateError(file, 'delivery provenance', retiredCandidate); + } + candidatePresent = false; + const finalized = verifyRegularFileBinding(candidateBinding, { + filePath: capture.commitPath, + expectedLinks: 1, + }); + if (finalized.status !== 'match') { + throw deliveryTargetStateError(file, 'delivery provenance', finalized); + } + if (backupPresent) { + const currentBackup = verifyRegularFileBinding(previousBinding, { + filePath: backup, + expectedLinks: 1, + }); + if (currentBackup.status !== 'match') { + backupVerified = false; + throw deliveryTargetStateError(file, 'delivery provenance', { + status: 'different', + reason: { code: 'backup-identity-changed-before-finalize' }, + }); + } + const retiredBackup = quarantineRemoveRegularFileBinding( + previousBinding, + backup, + { subject: 'previous-delivery-provenance', expectedLinks: 1 }, + ); + if (retiredBackup.status !== 'removed') { + retainStaging = true; + throw deliveryTargetStateError(file, 'delivery provenance', retiredBackup); + } + backupPresent = false; + } + } catch (cause) { + const rollbackErrors = []; + const publicRecoveryFiles = []; + if (cause.deliveryTargetState?.status === 'recovery-required' + && cause.deliveryTargetState.recoveryFile) { + publicRecoveryFiles.push({ + label: 'preserved provenance replacement', + path: cause.deliveryTargetState.recoveryFile, + target: capture.commitPath, + }); + } + if (published) { + try { + const removed = quarantineRemoveRegularFileBinding(candidateBinding, capture.commitPath, { + subject: 'published-delivery-provenance', + expectedLinks: candidatePresent ? 2 : 1, + }); + if (removed.status === 'removed') { + published = false; + } else { + rollbackErrors.push(`the published provenance could not be removed safely (${removed.reason?.code || 'unknown target state'})`); + if (removed.status === 'recovery-required') { + publicRecoveryFiles.push({ + label: 'preserved provenance replacement', + path: removed.recoveryFile, + target: capture.commitPath, + }); + } + } + } catch (error) { + if (error?.code !== 'ENOENT') rollbackErrors.push(`published provenance cleanup failed (${error.message})`); + } + } + if (candidatePresent) { + try { + const current = verifyRegularFileBinding(candidateBinding, { + filePath: candidate, + expectedLinks: published ? 2 : 1, + }); + if (current.status === 'match') { + const retiredCandidate = quarantineRemoveRegularFileBinding( + candidateBinding, + candidate, + { + subject: 'delivery-provenance-candidate', + expectedLinks: published ? 2 : 1, + }, + ); + if (retiredCandidate.status !== 'removed') { + throw deliveryTargetStateError( + file, + 'delivery provenance', + retiredCandidate, + ); + } + candidatePresent = false; + } else { + rollbackErrors.push('the staged provenance candidate was replaced before cleanup'); + retainStaging = true; + } + } catch (error) { + if (error?.code !== 'ENOENT') { + rollbackErrors.push(`staged provenance cleanup failed (${error.message})`); + retainStaging = true; + } + } + } + if (backupPresent) { + try { + if (!backupVerified) { + throw new Error('the backup entry was never verified as the previous provenance'); + } + if (pathEntryExists(capture.commitPath)) { + throw new Error('a new target claimant prevents restoring the previous provenance'); + } + const currentBackup = verifyRegularFileBinding(previousBinding, { + filePath: backup, + expectedLinks: 1, + }); + if (currentBackup.status !== 'match') { + backupVerified = false; + throw new Error('the previous provenance backup identity changed'); + } + fs.linkSync(backup, capture.commitPath); + for (const target of [backup, capture.commitPath]) { + const restored = verifyRegularFileBinding(previousBinding, { + filePath: target, + expectedLinks: 2, + }); + if (restored.status !== 'match') { + throw new Error('the restored provenance identity could not be verified'); + } + } + const retiredBackup = quarantineRemoveRegularFileBinding( + previousBinding, + backup, + { subject: 'previous-delivery-provenance', expectedLinks: 2 }, + ); + if (retiredBackup.status !== 'removed') { + throw deliveryTargetStateError(file, 'delivery provenance', retiredBackup); + } + backupPresent = false; + const restoredFinal = verifyRegularFileBinding(previousBinding, { + filePath: capture.commitPath, + expectedLinks: 1, + }); + if (restoredFinal.status !== 'match') { + throw new Error('the restored provenance link count could not be verified'); + } + } catch (error) { + rollbackErrors.push(`previous provenance restore failed (${error.message})`); + retainStaging = true; + } + } + const recoveryFiles = [ + ...(backupPresent ? [{ + label: backupVerified + ? 'preserved delivery provenance backup entry' + : 'unverified preserved delivery provenance backup entry', + path: backup, + target: capture.commitPath, + verifiedPrevious: backupVerified, + }] : []), + ...(candidatePresent && retainStaging ? [{ label: 'preserved staged provenance entry', path: candidate }] : []), + ...publicRecoveryFiles, + ]; + if (rollbackErrors.length) { + cause.deliveryProvenanceRecovery = { + recoveryRequired: true, + rollbackErrors, + ...(recoveryFiles.length ? { + recoveryDirectory: retainStaging + ? stagingDirectory + : path.dirname(recoveryFiles[0].path), + recoveryFiles, + } : {}), + }; + } + if (rollbackErrors.length && cause?.archifyDiagnostics?.[0]) { + const [issue] = cause.archifyDiagnostics; + issue.evidence = { + ...(issue.evidence || {}), + rollbackErrors, + ...(recoveryFiles.length ? { + recoveryDirectory: retainStaging + ? stagingDirectory + : path.dirname(recoveryFiles[0].path), + recoveryFile: recoveryFiles[0].path, + recoveryFiles, + } : {}), + }; + } + throw cause; + } finally { + if (candidateBinding) releaseRegularFileBinding(candidateBinding); + if (previousBinding) releaseRegularFileBinding(previousBinding); + if (!retainStaging) removeOwnedEmptyStagingDirectory(stagingDirectory, staging.identity); + } +} + +function recordDeliveryFailure(options) { + const { + output, stage, input, error, receiptId, pathsAlias, ownership: suppliedOwnership, + releaseOwnership: shouldReleaseSuppliedOwnership = false, legacyOutputPaths = [], + captureAtomicOutput, verifyAtomicOutput, fileBindingRuntime, + } = options; + if (!output || !/\.html?$/i.test(output)) return { ok: true, status: 'not-applicable' }; + let ownership = suppliedOwnership; + let acquiredHere = false; + if (!ownership) { + try { + ownership = acquireDeliveryLock( + output, + receiptId, + input, + pathsAlias, + (lockError, initializingOwnership) => recordDeliveryFailure({ + ...options, + error: `Could not start delivery for "${output}": ${lockError.message}`, + ownership: initializingOwnership, + releaseOwnership: false, + }), + legacyOutputPaths, + fileBindingRuntime, + ); + acquiredHere = true; + } catch (lockError) { + const reportedLockError = lockError?.code === 'ARCHIFY_SIDECAR_NAMESPACE_INDETERMINATE' + && lockError.sidecarNamespaceReason?.code === 'sidecar-directory-missing' + ? Object.assign( + new Error(`Delivery lock parent directory does not exist for "${output}".`), + { code: 'ENOENT', cause: lockError }, + ) + : lockError; + return { + ...(lockError.deliveryFailureRecord || { ok: false, status: 'unrecorded' }), + lockError: reportedLockError, + }; + } + } + const state = deliveryOwnershipStates.get(ownership); + let recorded; + if (!state?.deliveryTargets) { + try { + captureDeliveryTargetState({ + state, + requestedOutput: legacyOutputPaths[0] || output, + pathsAlias, + captureAtomicOutput, + verifyAtomicOutput, + }); + } catch (captureError) { + recorded = { + ok: false, + status: 'unrecorded', + diagnostic: captureError.archifyDiagnostics?.[0] || diagnostic({ + code: 'output/target-indeterminate', + message: captureError.message, + subject: { output }, + evidence: { ...(captureError?.code ? { systemCode: captureError.code } : {}) }, + supportedFixes: ['use ordinary absent or regular-file delivery targets, then retry'], + }), + }; + } + } + const stableOutput = state?.output || canonicalExistingDeliveryOutput(output); + // Keep independent evidence even when the artifact is unreadable or the + // provenance target is locked, aliases the input, or cannot be replaced. + let journalError; + if (!recorded && !state?.pendingIdentity) { + try { + beginDeliveryAttempt({ ownership, input, pathsAlias, fileBindingRuntime }); + } catch (cause) { + if (cause.deliveryOwnershipCode === 'delivery/ownership-lost') { + recorded = { ok: false, status: 'unrecorded', ownershipError: cause }; + } else if (cause.deliveryJournalRecovery?.recoveryRequired) { + recorded = { + ok: false, + status: 'unrecorded', + journalRecovery: cause, + diagnostic: deliveryJournalRecoveryDiagnostic(stableOutput, cause), + }; + } + journalError = cause; + } + } + if (!recorded) { + let artifact; + let artifactBinding; + const { + captureRegularFileBinding, + verifyRegularFileBinding, + releaseRegularFileBinding, + } = fileBindingRuntime || {}; + try { + if (typeof captureRegularFileBinding === 'function') { + const artifactCapture = captureRegularFileBinding(stableOutput, { + subject: 'failed-delivery-artifact', + expectedLinks: 1, + includeContent: true, + }); + if (artifactCapture.status === 'captured') { + artifact = artifactCapture.content.buffer; + artifactBinding = artifactCapture.binding; + } else if (['ENOENT', 'ENOTDIR'].includes(artifactCapture.reason?.systemCode)) { + recorded = { ok: true, status: 'absent' }; + } + // A failed marker does not need an artifact hash to invalidate old + // evidence. Non-regular or changing paths are never followed. + } + if (!recorded) { + const provenancePath = state?.provenancePath || deliveryProvenancePath(stableOutput); + let aliasesProtectedPath; + try { + aliasesProtectedPath = (input && pathsAlias(provenancePath, input)) + || pathsAlias(provenancePath, stableOutput); + } catch (aliasError) { + recorded = { + ok: false, + status: 'unrecorded', + diagnostic: diagnostic({ + code: 'delivery/provenance-path-resolution', + message: 'Delivery failure provenance could not resolve its target safely.', + subject: { output: stableOutput, provenance: provenancePath }, + evidence: { + reason: aliasError.message, + ...(aliasError?.code ? { systemCode: aliasError.code } : {}), + }, + supportedFixes: ['remove the sidecar path conflict or symbolic-link cycle, then rerun deliver'], + }), + }; + } + if (!recorded && aliasesProtectedPath) { + recorded = { + ok: false, + status: 'unrecorded', + diagnostic: diagnostic({ + code: 'delivery/provenance-target-alias', + message: 'Delivery failure provenance could not be recorded without replacing an input or the artifact.', + subject: { output: stableOutput, provenance: provenancePath }, + evidence: { ...(input ? { input: path.resolve(input) } : {}) }, + supportedFixes: ['choose an output whose .delivery.json sidecar is distinct from every input and the HTML artifact'], + }), + }; + } + if (!recorded) { + try { + writeCapturedDeliveryProvenance(provenancePath, { + schemaVersion: 1, + receiptId, + status: 'failed', + command: 'deliver', + stage, + input, + output: stableOutput, + ...(artifact ? { artifact: artifactIdentity(artifact) } : {}), + error, + }, { + beforeCommit: () => { + assertDeliveryOwnership(ownership, 'record failed delivery provenance', { + allowInitializing: true, + pending: state?.pendingIdentity ? 'owned' : 'ignore', + }); + if (artifactBinding) { + const verification = verifyRegularFileBinding(artifactBinding, { + filePath: stableOutput, + expectedLinks: 1, + }); + if (verification.status !== 'match') { + throw deliveryTargetStateError( + stableOutput, + 'failed delivery artifact', + verification, + ); + } + } + }, + capture: state?.deliveryTargets?.provenance, + verifyAtomicOutput, + fileBindingRuntime, + }); + recorded = { ok: true, status: 'failed' }; + } catch (writeError) { + if (writeError.deliveryOwnershipCode === 'delivery/ownership-lost') { + recorded = { ok: false, status: 'unrecorded', ownershipError: writeError }; + } else if (writeError.deliveryProvenanceRecovery?.recoveryRequired) { + const recovery = writeError.deliveryProvenanceRecovery; + recorded = { + ok: false, + status: 'unrecorded', + provenanceRecovery: writeError, + diagnostic: diagnostic({ + code: 'delivery/provenance-recovery-required', + message: 'Failed-delivery provenance could not be rolled back safely.', + subject: { output: stableOutput, provenance: provenancePath }, + evidence: { + reason: writeError.message, + recoveryRequired: true, + ...(recovery.recoveryDirectory ? { recoveryDirectory: recovery.recoveryDirectory } : {}), + ...(recovery.recoveryFiles?.length ? { recoveryFiles: recovery.recoveryFiles } : {}), + ...(recovery.rollbackErrors?.length ? { rollbackErrors: recovery.rollbackErrors } : {}), + }, + supportedFixes: ['leave the delivery lock and journal untouched, inspect the retained recovery files, then recover serially'], + }), + }; + } else if (writeError.deliveryTargetState) { + recorded = { + ok: false, + status: 'unrecorded', + diagnostic: writeError.archifyDiagnostics?.[0], + }; + } else { + recorded = { + ok: false, + status: 'unrecorded', + diagnostic: diagnostic({ + code: 'delivery/provenance-write', + message: 'The failed delivery could not persist its stale-artifact marker.', + subject: { output: stableOutput, provenance: provenancePath }, + evidence: { + reason: writeError.message, + ...(writeError?.code ? { systemCode: writeError.code } : {}), + failureJournalRecorded: !journalError, + ...(journalError ? { journalError: journalError.message } : {}), + }, + supportedFixes: ['restore write access to the output directory, then rerun deliver before trusting the artifact'], + }), + }; + } + } + } + } + } finally { + if (artifactBinding && typeof releaseRegularFileBinding === 'function') { + releaseRegularFileBinding(artifactBinding); + } + } + } + if (recorded?.journalRecovery || recorded?.provenanceRecovery) return recorded; + if (acquiredHere || shouldReleaseSuppliedOwnership) { + try { + releaseDeliveryOwnership(ownership, { allowInitializing: true }); + } catch (lockError) { + if (lockError.deliveryOwnershipCode === 'delivery/ownership-lost') { + const ownershipError = recorded.ownershipError || lockError; + if (options.recoveryDirectory) { + ownershipError.deliveryCommitDetails = { + ...(ownershipError.deliveryCommitDetails || {}), + recoveryRequired: true, + recoveryDirectory: options.recoveryDirectory, + recoverableBackups: [], + }; + } + return { + ...recorded, + ok: false, + status: 'unrecorded', + ownershipError, + lockError, + }; + } + return { ...recorded, lockError: recorded.ownershipError || lockError }; + } + } + return recorded; +} + +// Locale warnings for a successfully rendered candidate. Diagnostic renderers +// record them without printing text; validate/deliver disclose them before +// checking the artifact and reuse the same diagnostics in successful receipts. +async function specificationLocaleDiagnostics(type, specification) { + let meta; + try { + meta = JSON.parse(String(specification)).meta; + } catch { + return []; + } + if (!meta?.locale) return []; + const { localeDiagnostics } = await import('../renderers/shared/i18n.mjs'); + return localeDiagnostics(type, meta); +} + +function deliverySuccessProvenance(receipt) { + return { + schemaVersion: 1, + receiptId: receipt.receiptId, + status: 'current', + command: 'deliver', + type: receipt.type, + input: receipt.input, + output: receipt.output, + specification: receipt.specification, + artifact: receipt.artifact, + ...(receipt.explainer ? { explainer: receipt.explainer } : {}), + }; +} + +function inspectDeliveryProvenance(artifactPath, artifact, { + requireProvenance = false, + pathIdentityRuntime, + expectedSidecar, + inspection, +} = {}) { + let sidecar = deliveryProvenancePath(artifactPath); + const rawHeadSidecar = rawHeadDeliveryProvenancePath(artifactPath); + const pending = deliveryPendingPath(artifactPath); + const rawHeadPending = rawHeadDeliveryPendingPath(artifactPath); + const legacyExtensionPending = legacyExtensionDeliverySidecarPath( + artifactPath, + '.delivery-pending.json', + ); + const legacyExtensionSidecar = legacyExtensionDeliverySidecarPath( + artifactPath, + '.delivery.json', + ); + const stableOutput = canonicalExistingDeliveryOutput(artifactPath); + const locks = [ + deliveryLockPath(stableOutput), + ...legacyDeliveryLockPaths([artifactPath, stableOutput]), + ]; + let foundPending; + try { + for (const candidate of [...new Set([ + pending, + rawHeadPending, + legacyExtensionPending, + ].filter(Boolean))]) { + if (pathEntryExists(candidate)) { + foundPending = candidate; + break; + } + } + if (foundPending) { + return { + ok: false, status: 'failed', + diagnostics: [diagnostic({ + code: 'delivery/provenance-failed', + message: 'A delivery attempt is unfinished or failed; complete a successful deliver before trusting this artifact.', + subject: { artifact: path.resolve(artifactPath), provenance: sidecar }, + evidence: { pendingJournal: foundPending, found: true }, + supportedFixes: ['finish any active delivery, then rerun deliver successfully; retain the pending journal until recovery succeeds'], + })], + }; + } + } catch (error) { + return invalidProvenance(artifactPath, foundPending || pending, error.message); + } + for (const lock of locks) { + try { + if (pathEntryExists(lock)) { + return { + ok: false, status: 'locked', + diagnostics: [diagnostic({ + code: 'delivery/provenance-locked', + message: 'Delivery ownership is unresolved while the output lock remains; complete or recover delivery before trusting this artifact.', + subject: { artifact: path.resolve(artifactPath), lock }, + evidence: { deliveryLock: lock, found: true }, + supportedFixes: ['finish the active delivery or inspect and recover the preserved lock, then rerun deliver successfully'], + })], + }; + } + } catch (error) { + return invalidProvenance(artifactPath, lock, error.message); + } + } + let found; + try { + found = pathEntryExists(sidecar); + } catch (error) { + return invalidProvenance(artifactPath, sidecar, error.message); + } + if (!found && rawHeadSidecar) { + try { + found = pathEntryExists(rawHeadSidecar); + if (found) sidecar = rawHeadSidecar; + } catch (error) { + return invalidProvenance(artifactPath, rawHeadSidecar, error.message); + } + } + if (!found && legacyExtensionSidecar) { + try { + found = pathEntryExists(legacyExtensionSidecar); + if (found) sidecar = legacyExtensionSidecar; + } catch (error) { + return invalidProvenance(artifactPath, legacyExtensionSidecar, error.message); + } + } + if (!found) { + if (!requireProvenance) return { ok: true, status: 'unknown' }; + const message = 'Delivery provenance is required, but the artifact has no .delivery.json sidecar.'; + return { + ok: false, + status: 'unknown', + diagnostics: [diagnostic({ + code: 'delivery/provenance-required', + message, + subject: { artifact: path.resolve(artifactPath), provenance: sidecar }, + evidence: { required: true, found: false }, + supportedFixes: ['rerun deliver for the current specification, then retry with --require-provenance'], + })], + }; + } + let receipt; + let sidecarCapture; + try { + const { + captureRegularFileBinding, + releaseRegularFileBinding, + } = pathIdentityRuntime || {}; + if (typeof captureRegularFileBinding !== 'function' + || typeof releaseRegularFileBinding !== 'function') { + throw new Error('The regular-file binding runtime is unavailable.'); + } + sidecarCapture = captureRegularFileBinding(sidecar, { + subject: 'delivery-provenance', + ...(expectedSidecar ? { + expectedIdentity: expectedSidecar.identity, + expectedSha256: expectedSidecar.content.sha256, + expectedBytes: expectedSidecar.content.bytes, + expectedMode: expectedSidecar.mode, + } : {}), + expectedLinks: 1, + includeContent: true, + }); + if (sidecarCapture.status !== 'captured') { + if (sidecarCapture.reason?.code === 'delivery-provenance-hardlinked') { + return { + ok: false, + status: 'unsupported', + diagnostics: [diagnostic({ + code: 'delivery/provenance-hardlink-unsupported', + message: 'Delivery provenance is hard-linked and cannot prove that every linked name was updated atomically.', + subject: { artifact: path.resolve(artifactPath), provenance: sidecar }, + evidence: { links: sidecarCapture.reason.links }, + supportedFixes: ['remove extra hard links to the provenance sidecar, then rerun deliver'], + })], + }; + } + throw new Error(`Delivery provenance is not a stable regular file (${sidecarCapture.reason?.code || 'unknown target state'}).`); + } + receipt = JSON.parse(sidecarCapture.content.buffer.toString('utf8')); + const identityValid = (value) => value && typeof value.sha256 === 'string' && /^[0-9a-f]{64}$/.test(value.sha256) + && Number.isSafeInteger(value.bytes) && value.bytes >= 0; + if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt) + || receipt.schemaVersion !== 1 || receipt.command !== 'deliver' + || typeof receipt.receiptId !== 'string' + || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(receipt.receiptId) + || typeof receipt.output !== 'string' || !path.isAbsolute(receipt.output) + || typeof receipt.input !== 'string' || !path.isAbsolute(receipt.input) + || !['current', 'failed'].includes(receipt.status) + || (receipt.status === 'current' && (!TYPES.has(receipt.type) + || !identityValid(receipt.specification) || !identityValid(receipt.artifact))) + || (receipt.status === 'failed' && typeof receipt.stage !== 'string')) { + throw new Error('Delivery provenance does not conform to the supported receipt schema.'); + } + if (inspection) { + inspection.sidecar = { + path: sidecar, + identity: { + device: sidecarCapture.identity.device, + inode: sidecarCapture.identity.inode, + }, + content: { + sha256: sidecarCapture.content.sha256, + bytes: sidecarCapture.content.bytes, + }, + mode: sidecarCapture.mode, + }; + } + } catch (error) { + const message = `Could not read delivery provenance: ${error.message}`; + return { + ok: false, + status: 'invalid', + diagnostics: [diagnostic({ + code: 'delivery/provenance-invalid', + message, + subject: { artifact: path.resolve(artifactPath), provenance: sidecar }, + evidence: { reason: error.message }, + supportedFixes: ['rerun deliver to replace the invalid provenance sidecar'], + })], + }; + } finally { + if (sidecarCapture?.binding) { + pathIdentityRuntime.releaseRegularFileBinding(sidecarCapture.binding); + } + } + let outputIdentity; + if (typeof pathIdentityRuntime?.sameEntry !== 'function') { + const error = pathIdentityRuntime?.error; + outputIdentity = { + status: 'unknown', + reason: { + code: 'path-semantics-runtime-unavailable', + ...(typeof error?.code === 'string' ? { systemCode: error.code } : {}), + }, + }; + } else { + try { + outputIdentity = pathIdentityRuntime.sameEntry(receipt.output, artifactPath); + } catch (error) { + outputIdentity = { + status: 'unknown', + reason: { + code: 'path-identity-check-failed', + ...(typeof error?.code === 'string' ? { systemCode: error.code } : {}), + }, + }; + } + } + if (outputIdentity.status === 'unknown') { + return { + ok: false, + status: 'invalid', + diagnostics: [diagnostic({ + code: 'delivery/provenance-output-indeterminate', + message: 'Delivery provenance could not determine whether its recorded output identifies the inspected artifact.', + subject: { + artifact: path.resolve(artifactPath), + provenance: sidecar, + recordedOutput: receipt.output, + }, + evidence: { pathIdentity: outputIdentity.reason }, + supportedFixes: ['restore access to both the artifact and its recorded output path, then rerun deliver successfully'], + })], + }; + } + if (outputIdentity.status !== 'match') { + return invalidProvenance( + artifactPath, + sidecar, + 'The recorded delivery output does not identify this artifact.', + { recordedOutput: receipt.output, pathIdentity: outputIdentity.reason }, + ); + } + const actualSha256 = createHash('sha256').update(artifact).digest('hex'); + if (receipt.status === 'failed') { + const message = 'The artifact is stale because the latest delivery attempt failed.'; + return { + ok: false, + status: 'failed', + receiptId: receipt.receiptId, + diagnostics: [diagnostic({ + code: 'delivery/provenance-failed', + message, + subject: { artifact: path.resolve(artifactPath), provenance: sidecar }, + evidence: { stage: receipt.stage, deliveryReceiptId: receipt.receiptId }, + supportedFixes: ['repair the specification and complete a successful deliver before checking this artifact'], + })], + }; + } + if (receipt.artifact?.sha256 !== actualSha256 || receipt.artifact?.bytes !== artifact.byteLength) { + const message = 'The artifact bytes do not match their delivery provenance.'; + return { + ok: false, + status: 'mismatch', + receiptId: receipt.receiptId, + diagnostics: [diagnostic({ + code: 'delivery/provenance-mismatch', + message, + subject: { artifact: path.resolve(artifactPath), provenance: sidecar }, + evidence: { + expectedSha256: receipt.artifact?.sha256, + actualSha256, + expectedBytes: receipt.artifact?.bytes, + actualBytes: artifact.byteLength, + }, + supportedFixes: ['restore the delivered artifact or rerun deliver to create a matching artifact and provenance pair'], + })], + }; + } + return { ok: true, status: 'current', receiptId: receipt.receiptId, artifact: receipt.artifact, type: receipt.type }; +} + +function invalidProvenance(artifactPath, sidecar, reason, evidence = {}) { + return { + ok: false, status: 'invalid', + diagnostics: [diagnostic({ + code: 'delivery/provenance-invalid', message: 'Delivery provenance could not be verified.', + subject: { artifact: artifactPath, provenance: sidecar }, evidence: { reason, ...evidence }, + supportedFixes: ['restore access to the delivery records, then rerun deliver successfully'], + })], + }; +} + +const COMMAND_USAGE = { + render: ' [output.html] [--quality standard|showcase] [--repo-root path]', + compare: 'architecture [output.html] [--receipt path] [--json] [--quality standard|showcase] [--repo-root path]', + deliver: ' [output.html] [--json] [--open] [--quality standard|showcase] [--repo-root path]', + patch: 'explainer [--json] [--quality standard|showcase] [--repo-root path]', + finalize: ' [--json] [--browser-check] [--receipt path] [--out-dir ] [--quality standard|showcase] [--repo-root path] [--candidate-sha256 hex]', + preview: ' [output.html] [--no-open] [--quality standard|showcase] [--repo-root path]', + validate: ' [--json] [--layout-json] [--quality standard|showcase] [--repo-root path]', + migrate: 'workflow --to-schema 2 [--output portable.html] [--json] [--repo-root path]', + inspect: ' ', + check: ' [--json] [--require-provenance]', + 'browser-check': ' [--json|--summary] [--require-provenance] [--out-dir ]', + 'visual-check': ' [--json|--summary] [--require-provenance] [--out-dir ]', + guide: '[scenario or question] [--json] [--lang en|zh]', + brands: ['[name, alias, domain, or category] [--json]', 'capture [--json]'], + examples: '', + doctor: '', + demo: '[output-directory]', +}; + +function usage() { + const commands = Object.entries(COMMAND_USAGE).flatMap(([command, forms]) => + (Array.isArray(forms) ? forms : [forms]).map(form => ` archify ${command}${form ? ` ${form}` : ''}`)); + return `Usage: +${commands.join('\n')} + (finalize --out-dir relocates browser-check and default finalize receipts; HTML and its delivery receipt stay at the requested output path) + +Types: + architecture, workflow, sequence, dataflow, lifecycle, erd, tree, class, timeline, waterfall, explainer + +Explainer writing checks (validate, deliver, finalize, patch): + --writing warn|strict|off (default: warn) +`; +} + +function fail(message, code = 2) { + console.error(message); + process.exit(code); +} + +function rejectCliArgument(message, details = {}) { + const error = new Error(message); + error.archifyArgument = { + code: details.code || 'cli/invalid-arguments', + subject: details.subject || {}, + evidence: details.evidence || {}, + supportedFixes: details.supportedFixes || ['correct the command arguments and retry'], + }; + throw error; +} + +function rendererPath(type) { + if (!TYPES.has(type)) { + rejectCliArgument(`Unknown diagram type "${type}". Expected one of: ${[...TYPES].join(', ')}`, { + code: 'cli/unknown-diagram-type', + subject: { type }, + evidence: { supportedTypes: [...TYPES] }, + supportedFixes: [`use one of: ${[...TYPES].join(', ')}`], + }); + } + return path.join(skillRoot, 'renderers', type, `render-${type}.mjs`); +} + +// Default capture ceiling for piped child output. spawnSync's own default +// is 1 MiB, which is below what a dense diagram's checker receipt reaches. +const DEFAULT_MAX_BUFFER = 64 * 1024 * 1024; + +function runNode(args, options = {}) { + const result = spawnSync(process.execPath, args, { + cwd: options.cwd || process.cwd(), + encoding: 'utf8', + stdio: options.stdio || 'inherit', + maxBuffer: options.maxBuffer ?? DEFAULT_MAX_BUFFER, + env: options.env ? { ...process.env, ...options.env } : process.env, + }); + // spawnSync can report a system failure after a child has exited zero. A + // truncated renderer or checker result is never a successful invocation. + if (result.error && result.status === 0) { + result.archifyRawStatus = result.status; + result.status = 1; + } + return result; +} + +// The artifact checker prints its whole receipt on stdout; a dense, +// warning-heavy diagram can grow that receipt past spawnSync's 1 MiB +// default, which kills the child before it reports a verdict. Deliver, +// validate, migrate, compare, and check share this explicit limit so one +// environment knob moves them together. +function artifactCheckMaxBuffer() { + const raw = process.env.ARCHIFY_CHECK_MAX_BUFFER; + if (raw === undefined || raw === '') return DEFAULT_MAX_BUFFER; + const parsed = Number(raw); + return Number.isFinite(parsed) && Math.floor(parsed) >= 1 ? Math.floor(parsed) : DEFAULT_MAX_BUFFER; +} + +// A checker terminated while streaming its output never judged the +// artifact; its truncated stdout must not be reinterpreted as a verdict. +function checkerOutputLimitDiagnostics(check, limitBytes) { + if (check.error?.code !== 'ENOBUFS') return null; + const receivedBytes = Buffer.byteLength(check.stdout || '', 'utf8'); + return [diagnostic({ + code: 'artifact/check-output-limit', + message: `The artifact checker exceeded the ${limitBytes} byte output buffer and was terminated before reporting a verdict (${receivedBytes} stdout bytes received); the artifact was not judged.`, + subject: { check: 'render_output' }, + evidence: { + systemCode: check.error.code, + ...(check.signal ? { signal: check.signal } : {}), + status: check.archifyRawStatus ?? check.status ?? null, + limitBytes, + receivedBytes, + }, + supportedFixes: [ + 'set ARCHIFY_CHECK_MAX_BUFFER to a much larger limit (for example 1073741824) and re-run', + ], + })]; +} + +function extractQualityArgs(args) { + const rest = []; + let quality; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--quality') { + quality = args[index + 1]; + if (!quality || quality.startsWith('--')) rejectCliArgument('--quality requires standard or showcase.', { + code: 'cli/missing-option-value', + subject: { option: '--quality' }, + supportedFixes: ['provide --quality standard or --quality showcase'], + }); + index += 1; + continue; + } + if (arg.startsWith('--quality=')) { + quality = arg.slice('--quality='.length); + if (!quality) rejectCliArgument('--quality requires standard or showcase.', { + code: 'cli/missing-option-value', + subject: { option: '--quality' }, + supportedFixes: ['provide --quality standard or --quality showcase'], + }); + continue; + } + rest.push(arg); + } + if (quality !== undefined && !['standard', 'showcase'].includes(quality)) { + rejectCliArgument(`Unknown quality profile "${quality}". Expected standard or showcase.`, { + code: 'cli/invalid-option-value', + subject: { option: '--quality' }, + evidence: { value: quality, supportedValues: ['standard', 'showcase'] }, + supportedFixes: ['use --quality standard or --quality showcase'], + }); + } + return { rest, quality }; +} + +function extractRepoRootArgs(args) { + const rest = []; + let repoRoot; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--repo-root') { + repoRoot = args[index + 1]; + if (!repoRoot || repoRoot.startsWith('--')) rejectCliArgument('--repo-root requires a repository path.', { + code: 'cli/missing-option-value', + subject: { option: '--repo-root' }, + supportedFixes: ['provide one repository path after --repo-root'], + }); + index += 1; + continue; + } + if (arg.startsWith('--repo-root=')) { + repoRoot = arg.slice('--repo-root='.length); + if (!repoRoot) rejectCliArgument('--repo-root requires a repository path.', { + code: 'cli/missing-option-value', + subject: { option: '--repo-root' }, + supportedFixes: ['provide one repository path after --repo-root'], + }); + continue; + } + rest.push(arg); + } + return { rest, repoRoot: repoRoot ? path.resolve(repoRoot) : undefined }; +} + +function extractOutDirArgs(args) { + const rest = []; + let outDir; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--out-dir') { + outDir = args[index + 1]; + if (!outDir || outDir.startsWith('--')) fail('--out-dir requires a directory path.'); + index += 1; + continue; + } + if (arg.startsWith('--out-dir=')) { + outDir = arg.slice('--out-dir='.length); + if (!outDir) fail('--out-dir requires a directory path.'); + continue; + } + rest.push(arg); + } + return { rest, outDir }; +} + +function extractExplainerWritingArgs(args) { + const rest=[]; + let writingMode; + for(let index=0;index!argument.startsWith('--'))!=='explainer') rejectCliArgument('--writing is supported for explainer documents only.', { + code:'cli/unsupported-option',subject:{option:'--writing'},supportedFixes:['Remove --writing or use an explainer document.'], + }); + return {rest,writingMode}; +} + +async function prepareExplainerInput(specification, inputPath, options = {}) { + const runtime = await import('../explainer/prepare.mjs'); + const text = specification.toString('utf8'); + if (!Buffer.from(text).equals(specification)) { + const message='Explainer draft must contain valid UTF-8 bytes.'; + throw Object.assign(new Error(message),{archifyDiagnostics:[diagnostic({code:'explainer/source-encoding',message,subject:{input:inputPath,line:1,component:'document'},evidence:{reason:'invalid-utf8',example:'Save the original draft as UTF-8 text.'},supportedFixes:['Save the original draft as UTF-8 text.']})]}); + } + return process.env.ARCHIFY_EXPLAINER_PREPARED + ? runtime.readPreparedBundle(process.env.ARCHIFY_EXPLAINER_PREPARED, text, inputPath, { quality: options.quality, writingMode: options.writingMode || 'warn' }) + : runtime.prepareExplainer(text, { ...options, inputPath }); +} + +function rendererEnv(quality, repoRoot, diagnosticJson = false) { + return { + ...(quality ? { ARCHIFY_QUALITY_PROFILE: quality } : {}), + ...(repoRoot ? { ARCHIFY_REPO_ROOT: repoRoot } : {}), + ...(diagnosticJson ? { ARCHIFY_DIAGNOSTIC_FORMAT: 'json' } : {}), + }; +} + +function diagnostic({ code, message, subject = {}, evidence = {}, supportedFixes = [], severity = 'error' }) { + return { + code, + severity, + message, + subject, + evidence, + supportedFixes, + }; +} + +function deliveryLockFailureDiagnostic(output, error) { + const code = error.deliveryOwnershipCode || error.deliveryLockCode || 'delivery/lock-acquire'; + if (code === 'delivery/legacy-pending') { + return diagnostic({ + code, + message: `A pre-namespace delivery journal blocks delivery for "${output}".`, + subject: { output, journal: error.deliveryLockPath }, + evidence: { pendingJournal: error.deliveryLockPath, found: true }, + supportedFixes: ['recover the unfinished delivery and remove only its journal after recovery succeeds'], + }); + } + const lock = error.deliveryOwnershipDetails?.lock + || error.deliveryLockPath + || deliveryLockPath(output); + return diagnostic({ + code, + message: code === 'delivery/lock-release' + ? `Delivery lock could not be released for "${output}": ${error.message}` + : code === 'delivery/ownership-lost' + ? `Delivery ownership was lost for "${output}": ${error.message}` + : `Could not start delivery for "${output}": ${error.message}`, + subject: { output, lock }, + evidence: { + reason: error.message, + ...(error.code ? { systemCode: error.code } : {}), + ...(error.lockCleanupError ? { cleanupError: error.lockCleanupError } : {}), + ...(error.deliveryLockOwner || {}), + ...(error.deliveryOwnershipDetails || {}), + ...(error.deliveryCommitDetails || {}), + }, + supportedFixes: code === 'delivery/concurrent-attempt' + ? ['wait for the active delivery to finish, then retry'] + : code === 'delivery/lock-stale' + ? [ + 'confirm no delivery attempt is active for this output', + `remove only the preserved lock "${lock}", then rerun deliver successfully`, + ] + : code === 'delivery/lock-path-conflict' + ? ['choose an output whose lock path is distinct from the input specification'] + : code === 'delivery/lock-invalid' + ? ['inspect and preserve the unrecognized lock entry, or choose another output path'] + : code === 'delivery/ownership-lost' + ? ['leave the replacement lock and journal untouched, inspect the reported owner, then retry only after that attempt finishes or is recovered'] + : code === 'delivery/lock-release' + ? ['preserve the reported lock, restore permission to remove that exact entry, then recover serially before retrying'] + : ['resolve the reported filesystem error and any lock cleanup error, then retry'], + }); +} + +function deliveryJournalRecoveryDiagnostic(output, error) { + const recovery = error.deliveryJournalRecovery || {}; + const location = recovery.recoveryDirectory + ? ` Recovery material was retained at "${recovery.recoveryDirectory}".` + : ''; + return diagnostic({ + code: 'delivery/journal-recovery-required', + message: `Delivery journal publication could not be rolled back safely.${location}`, + subject: { output, journal: recovery.journal || deliveryPendingPath(output) }, + evidence: { + reason: error.message, + recoveryRequired: true, + ...(recovery.recoveryDirectory ? { recoveryDirectory: recovery.recoveryDirectory } : {}), + ...(recovery.recoveryFiles?.length ? { recoveryFiles: recovery.recoveryFiles } : {}), + ...(recovery.rollbackErrors?.length ? { rollbackErrors: recovery.rollbackErrors } : {}), + }, + supportedFixes: recovery.recoveryDirectory + ? [ + 'leave the delivery lock and current journal claimant untouched', + `inspect every recovery file in ${JSON.stringify(recovery.recoveryDirectory)} and restore the previous journal only after the claimant is resolved`, + ] + : ['leave the delivery lock and journal untouched, inspect their current owners, then recover serially'], + }); +} + +function deliveryCommitRecoveryDiagnostic(output, error) { + const recovery = error.deliveryCommitDetails || {}; + return diagnostic({ + code: 'delivery/commit-recovery-required', + message: `Delivery rollback could not safely restore the previous output pair for "${output}".`, + subject: { output }, + evidence: { + reason: recovery.reason || error.message, + recoveryRequired: true, + ...(recovery.recoveryDirectory ? { recoveryDirectory: recovery.recoveryDirectory } : {}), + ...(recovery.recoverableBackups?.length + ? { recoverableBackups: recovery.recoverableBackups } + : {}), + ...(recovery.rollbackErrors?.length ? { rollbackErrors: recovery.rollbackErrors } : {}), + }, + supportedFixes: [ + 'leave the delivery lock, journal, current claimants, and retained backups untouched', + 'inspect the reported files and restore the previous pair only after resolving every claimant', + ], + }); +} + +function deliveryTargetStateDiagnostic(output, role, result) { + const reason = result?.reason || { code: 'target-state-unavailable' }; + const hardlinked = typeof reason.code === 'string' && reason.code.endsWith('-hardlinked'); + const provenanceTarget = /provenance/iu.test(role); + const code = hardlinked + ? provenanceTarget ? 'delivery/provenance-hardlink-unsupported' : 'output/target-hardlinked' + : ['target-not-regular-file', 'requested-entry-symbolic-link', 'requested-entry-not-regular-file'].includes(reason.code) + ? 'output/target-not-regular-file' + : result?.status === 'different' + ? 'output/target-changed' + : 'output/target-indeterminate'; + const message = hardlinked + ? `The ${role} target is hard-linked and cannot be replaced atomically.` + : code === 'output/target-not-regular-file' + ? `The ${role} target is not a regular file.` + : code === 'output/target-changed' + ? `The ${role} target changed while the verified delivery pair was being prepared.` + : `The ${role} target identity could not be determined safely.`; + return diagnostic({ + code, + message, + subject: { output: path.resolve(output), role }, + evidence: { targetState: reason }, + supportedFixes: hardlinked + ? ['remove extra hard links or choose a new output path, then rerun deliver'] + : code === 'output/target-not-regular-file' + ? ['choose an absent or regular-file target, then rerun deliver'] + : code === 'output/target-changed' + ? ['retry only after other processes stop creating, replacing, retargeting, or changing the output pair'] + : ['use an ordinary local filesystem path with stable file identity, then retry'], + }); +} + +function deliveryTargetStateError(output, role, result) { + const issue = deliveryTargetStateDiagnostic(output, role, result); + const error = new Error(issue.message); + error.archifyDiagnostics = [issue]; + error.deliveryTargetState = result; + return error; +} + +function captureDeliveryTargetState({ + state, + requestedOutput, + pathsAlias, + captureAtomicOutput, + verifyAtomicOutput, + preparedTargets, +}) { + if (typeof captureAtomicOutput !== 'function' || typeof verifyAtomicOutput !== 'function') { + throw deliveryTargetStateError(requestedOutput, 'delivery pair', { + status: 'unknown', + reason: { code: 'atomic-output-runtime-unavailable' }, + }); + } + const artifactTarget = preparedTargets?.artifact || { + requestedPath: requestedOutput, + ...captureAtomicOutput(requestedOutput), + }; + if (artifactTarget.status !== 'captured') { + throw deliveryTargetStateError(requestedOutput, 'HTML artifact', artifactTarget); + } + if (!pathsAlias(artifactTarget.commitPath, state.output)) { + throw deliveryTargetStateError(requestedOutput, 'HTML artifact', { + status: 'different', + reason: { + code: 'write-slot-changed-before-snapshot', + plannedCommitPath: state.output, + currentCommitPath: artifactTarget.commitPath, + }, + }); + } + const requestedProvenancePath = preparedTargets?.provenance?.requestedPath + || deliveryProvenancePath(artifactTarget.commitPath); + const provenanceTarget = preparedTargets?.provenance || { + requestedPath: requestedProvenancePath, + ...captureAtomicOutput(requestedProvenancePath, { + requestedEntryPolicy: 'regular-or-absent', + }), + }; + if (provenanceTarget.status !== 'captured') { + throw deliveryTargetStateError(requestedProvenancePath, 'delivery provenance', provenanceTarget); + } + for (const [role, target] of [ + ['HTML artifact', artifactTarget], + ['delivery provenance', provenanceTarget], + ]) { + const verification = verifyAtomicOutput(target.snapshot); + if (verification.status !== 'match') { + throw deliveryTargetStateError(target.requestedPath, role, verification); + } + } + state.output = artifactTarget.commitPath; + state.provenancePath = provenanceTarget.commitPath; + state.pendingPath = deliveryPendingPath(artifactTarget.commitPath); + state.verifyAtomicOutput = verifyAtomicOutput; + state.deliveryTargets = { + artifact: artifactTarget, + provenance: provenanceTarget, + }; + return state.deliveryTargets; +} + +function inputDiagnostic(error, inputPath) { + const isSyntax = error instanceof SyntaxError; + return diagnostic({ + code: isSyntax ? 'input/json-parse' : 'input/read', + message: isSyntax + ? `Input JSON could not be parsed: ${error.message}` + : `Input could not be read: ${error.message}`, + subject: { input: inputPath }, + evidence: { + ...(error?.code ? { systemCode: error.code } : {}), + reason: error.message, + }, + supportedFixes: [isSyntax + ? 'repair the JSON syntax and run validation again' + : 'provide one readable JSON input file'], + }); +} + +function rendererFailure(result) { + if (result.error) { + return { + error: 'Renderer process could not start.', + diagnostics: [diagnostic({ + code: 'internal/renderer-process', + message: 'Renderer process could not start.', + evidence: { reason: result.error.message }, + })], + }; + } + try { + const payload = JSON.parse((result.stderr || '').trim()); + if (payload?.ok === false && Array.isArray(payload.diagnostics) && payload.diagnostics.length) { + return { + error: payload.error || payload.diagnostics[0].message, + diagnostics: payload.diagnostics, + }; + } + } catch { + // The diagnostic boundary is intentionally fail-closed. Never copy a raw + // Node stack into a machine receipt when a renderer exits unexpectedly. + } + return { + error: 'Renderer failed before emitting a structured diagnostic.', + diagnostics: [diagnostic({ + code: 'internal/unclassified', + message: 'Renderer failed before emitting a structured diagnostic.', + evidence: { exitCode: result.status ?? 1 }, + })], + }; +} + +const COMPOSITION_CHECKS = new Set([ + 'label_route_clearance', + 'relationship_crossings', + 'relationship_corridors', + 'container_border_runs', + 'route_rhythm', +]); + +const CHECK_FIXES = { + single_svg: ['remove additional SVG roots so the artifact contains exactly one diagram SVG'], + finite_svg: ['replace non-finite coordinates before rendering again'], + orthogonal_arrows: ['use renderer-supported orthogonal routing controls'], + legend_clearance: ['move the route or enlarge the viewBox so relationships do not enter the legend'], +}; + +const COMPOSITION_FIXES = { + 'composition/proper-crossing': ['if authored via/route/channelX/channelY controls exist and are not required by the user, remove them to let the renderer re-plan; otherwise preserve that intent and adjust route/via or channel coordinates so unrelated relationships use separate corridors'], + 'composition/ambiguous-corridor': ['if authored via/route/channelX/channelY controls exist and are not required by the user, remove them to let the renderer re-plan; otherwise preserve that intent and adjust route/via or channel coordinates so unrelated relationships do not visually merge'], + 'composition/container-border-run': ['if authored via/route/channelX/channelY controls exist and are not required by the user, remove them to let the renderer re-plan; otherwise preserve that intent and route across the frame perpendicularly through a clear opening'], + 'composition/label-route-clearance': ['if authored labelAt/labelDx/labelDy/labelSegment controls exist and are not required by the user, remove them to let the renderer re-plan; otherwise preserve that intent and adjust labelAt, labelDx, labelDy, labelSegment, message y, or the other relationship route'], + 'composition/label-canvas-containment': ['if authored labelAt/labelDx/labelDy/labelSegment controls exist and are not required by the user, remove them to let the renderer re-plan; otherwise preserve that intent and adjust labelAt, labelDx, labelDy, or labelSegment so the label rect stays inside the viewBox, or enlarge meta.viewBox'], + 'composition/micro-segment': ['if authored via/route/channelX/channelY controls exist and are not required by the user, remove them to let the renderer re-plan; otherwise preserve that intent and move the route/channel/via point so every visible segment is at least 8px'], + 'composition/short-interior-segment': ['if authored via/route/channelX/channelY controls exist and are not required by the user, remove them to let the renderer re-plan; otherwise preserve that intent and move the route/channel/via point so every interior turn has at least 16px'], +}; + +function checkerDiagnostics(checker) { + if (checker?.family === 'explainer' && checker.diagnostics?.some(entry => entry.severity === 'error')) return checker.diagnostics.filter(entry => entry.severity === 'error').map(entry => diagnostic(entry)); + const diagnostics = []; + for (const issue of checker?.composition?.issues || []) { + if (issue.severity !== 'error') continue; + const { severity, code, relationship, nodeId, ...evidence } = issue; + diagnostics.push(diagnostic({ + code, + severity, + message: `Final artifact failed ${code}.`, + subject: relationship ? { relationship } : { check: 'composition', ...(nodeId ? { nodeId } : {}) }, + evidence, + supportedFixes: compositionFixes(issue), + })); + } + for (const check of checker?.checks || []) { + if (check.ok || COMPOSITION_CHECKS.has(check.name)) continue; + if (check.name === 'svg_path_data') { + const pathDiagnostics = (checker.diagnostics || []).filter(item => ( + item?.severity === 'error' + && ['artifact/svg-path-malformed', 'artifact/svg-path-unsupported'].includes(item.code) + )); + if (pathDiagnostics.length) { + diagnostics.push(...pathDiagnostics.map(item => diagnostic(item))); + continue; + } + } + diagnostics.push(diagnostic({ + code: `artifact/${check.name.replaceAll('_', '-')}`, + message: (check.details || []).find(Boolean) || `Final artifact failed ${check.name}.`, + subject: { check: check.name }, + evidence: { details: check.details || [] }, + supportedFixes: CHECK_FIXES[check.name] || [], + })); + } + return diagnostics.length ? diagnostics : [diagnostic({ + code: 'artifact/check-failed', + message: 'Final artifact check failed without a classified diagnostic.', + subject: { check: 'unknown' }, + evidence: {}, + })]; +} + +function compositionFixes(issue) { + if (issue.code === 'composition/viewport-height') return [String(issue.detail || '').replace(/^\[[^\]]+\]\s*/, '')]; + if (issue.code !== 'composition/desktop-readability') return COMPOSITION_FIXES[issue.code] || []; + const sourceFontPx = Number(issue.sourceFontPx); + const actualBudgetPx = Number(issue.availableDiagramWidth); + const hardFloorPx = Number(issue.minimumProjectedFontPx); + const viewBoxWidth = Number(issue.viewBoxWidth); + const preserveIntent = 'Preserve the semantic text and any supplied coordinates, routes, sides, channels, and labels.'; + const readerCap = issue.budgetBasis === 'recognized-declared-wide' + ? ` The declared Reader reports a ${issue.budgetLimit} limit and ${actualBudgetPx}px actual diagram budget; do not assume an uncapped viewport.` + : ''; + if (![sourceFontPx, actualBudgetPx, hardFloorPx, viewBoxWidth].every(Number.isFinite) + || actualBudgetPx <= 0 || hardFloorPx <= 0 || viewBoxWidth <= 0) { + return [`${preserveIntent} Repair the measured source font, desktop budget, or complete viewBox width; position-only label controls do not change projected text size.`]; + } + if (sourceFontPx < hardFloorPx) { + return [`${preserveIntent} The diagnosed ${sourceFontPx}px source text is below the ${hardFloorPx}px hard floor even at scale 1, so use a renderer-supported semantic text-size setting or renderer-level fix. Position-only label controls cannot repair its projection.${readerCap}`]; + } + const maximumViewBoxWidth = Math.floor((sourceFontPx * actualBudgetPx) / hardFloorPx); + return [`${preserveIntent} Compactly reflow automatic spacing and empty corridors so the complete viewBox width is at most ${maximumViewBoxWidth}px (current ${viewBoxWidth}px; ${sourceFontPx}px source text at ${actualBudgetPx}px desktop budget). If supplied geometry fixes that width, use a renderer-supported semantic text-size setting or renderer-level fix instead. Position-only label controls cannot repair its projection.${readerCap}`]; +} + +function formatDiagnostics(error, diagnostics = []) { + if (!diagnostics.length) return error; + return [ + error, + ...diagnostics.map((entry) => { + const fix = entry.supportedFixes?.length ? ` Fix: ${entry.supportedFixes.join('; ')}.` : ''; + return `[${entry.code}] ${entry.message}${fix}`; + }), + ].join('\n'); +} + +function exitFrom(result) { + if (result.error) fail(result.error.message, 1); + process.exit(result.status ?? 1); +} + +function reportCompareFailure({ json, stage, error, code = 'delta/internal', details = {}, status = 1 }) { + const receipt = { + schemaVersion: 1, + ok: false, + command: 'compare', + type: 'architecture', + stage, + error, + diagnostics: [{ + code, + severity: 'error', + message: error, + subject: details.side ? { side: details.side, ...(details.path ? { path: details.path } : {}) } : {}, + evidence: Object.fromEntries(Object.entries(details).filter(([key]) => !['side', 'path', 'supportedFixes'].includes(key))), + supportedFixes: details.supportedFixes || [], + }], + }; + if (json) console.log(JSON.stringify(receipt, null, 2)); + else console.error(formatDiagnostics(error, receipt.diagnostics)); + process.exitCode = status; +} + +function extractCompareOptions(args) { + const positional = []; + let receipt; + let json = false; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--json') { + json = true; + continue; + } + if (arg === '--receipt') { + receipt = args[index + 1]; + if (!receipt || receipt.startsWith('--')) fail('--receipt requires a JSON output path.'); + index += 1; + continue; + } + if (arg.startsWith('--receipt=')) { + receipt = arg.slice('--receipt='.length); + if (!receipt) fail('--receipt requires a JSON output path.'); + continue; + } + if (arg.startsWith('--')) fail(`Unknown compare option "${arg}".`); + positional.push(arg); + } + return { positional, receipt, json }; +} + +function compareReceiptPath(outputPath) { + const { directory, stem, options } = deliverySidecarNamespace(outputPath); + return path.join( + directory, + `${boundedSidecarStem(stem, COMPARE_SIDECAR_SUFFIXES, options)}.receipt.json`, + ); +} + +function compareCommitError(message, code, details = {}) { + const error = new Error(message); + error.compareStage = 'commit'; + error.compareCode = code; + error.compareDetails = details; + return error; +} + +function compareAtomicOutputFailure(result, label, requestedPath, stage) { + const reason = result.reason || { code: 'atomic-output-unknown' }; + let code = 'output/target-indeterminate'; + let message = `The compare ${label} target identity could not be verified safely.`; + let supportedFixes = ['use an ordinary local filesystem path with stable file identity, then retry']; + if (typeof reason.code === 'string' && reason.code.endsWith('-hardlinked')) { + code = 'output/target-hardlinked'; + message = `The compare ${label} target has multiple hard links and cannot be replaced atomically.`; + supportedFixes = [`choose a ${label} path that is absent or names a regular file with one link`]; + } else if (reason.code === 'target-not-regular-file') { + code = 'output/target-not-regular-file'; + message = `The compare ${label} target is not a regular file.`; + supportedFixes = [`choose an absent or regular-file path for the ${label}`]; + } else if (result.status === 'different') { + code = 'output/target-changed'; + message = `The compare ${label} target changed while the artifact pair was being prepared.`; + supportedFixes = ['retry after other processes stop creating, replacing, or redirecting compare outputs']; + } + return { + stage, + code, + message, + details: { + side: label, + path: requestedPath, + atomicOutput: reason, + supportedFixes, + }, + }; +} + +function commitComparePair({ + htmlCandidate, + htmlContent, + receiptCandidate, + receiptContent, + outputCapture, + receiptCapture, + stagingDirectory, + verifyAtomicOutput, + fileBindingRuntime, +}) { + const { + backupPublicRegularFileBinding, + captureRegularFileBinding, + quarantineRemoveRegularFileBinding, + verifyRegularFileBinding, + releaseRegularFileBinding, + } = fileBindingRuntime || {}; + const targets = [ + { + label: 'HTML artifact', capture: outputCapture, candidate: htmlCandidate, content: htmlContent, backup: path.join(stagingDirectory, '.previous-output'), + }, + { + label: 'receipt', capture: receiptCapture, candidate: receiptCandidate, content: receiptContent, backup: path.join(stagingDirectory, '.previous-receipt'), + }, + ]; + const targetChanged = (item, result) => { + const normalized = typeof result === 'string' + ? { status: 'different', reason: { code: result } } + : result; + const failure = compareAtomicOutputFailure( + normalized, + item.label, + item.capture.snapshot.requestedPath, + 'commit', + ); + const error = compareCommitError(failure.message, failure.code, failure.details); + error.compareFailure = failure; + return error; + }; + + const backedUp = []; + const committed = []; + let retainSuccessfulBackupBindings = false; + try { + if (typeof backupPublicRegularFileBinding !== 'function' + || typeof captureRegularFileBinding !== 'function' + || typeof quarantineRemoveRegularFileBinding !== 'function' + || typeof verifyRegularFileBinding !== 'function' + || typeof releaseRegularFileBinding !== 'function') { + throw targetChanged(targets[0], { + status: 'unknown', + reason: { code: 'atomic-output-runtime-unavailable' }, + }); + } + // Re-resolve and bind both requested write slots and candidate inodes + // before moving either target. The held handles keep bytes, mode and + // identity authoritative across every publish/finalize transition. + for (const item of targets) { + const verification = verifyAtomicOutput(item.capture.snapshot); + if (verification.status !== 'match') throw targetChanged(item, verification); + if (item.capture.mode !== null) fs.chmodSync(item.candidate, item.capture.mode); + const candidate = captureRegularFileBinding(item.candidate, { + subject: 'compare-candidate', + expectedSha256: item.content.sha256, + expectedBytes: item.content.bytes, + ...(item.capture.mode === null ? {} : { expectedMode: item.capture.mode }), + expectedLinks: 1, + }); + if (candidate.status !== 'captured') throw targetChanged(item, candidate); + item.candidateBinding = candidate.binding; + item.candidatePresent = true; + item.candidateIdentity = { + dev: candidate.identity.device, + ino: candidate.identity.inode, + }; + item.candidateMode = candidate.mode; + if (item.capture.snapshot.target.kind === 'file') { + const expected = item.capture.snapshot.target; + const previous = captureRegularFileBinding(item.capture.commitPath, { + subject: 'previous-compare-target', + expectedIdentity: { device: expected.device, inode: expected.inode }, + expectedMode: expected.mode, + expectedLinks: 1, + }); + if (previous.status !== 'captured') throw targetChanged(item, previous); + item.previousBinding = previous.binding; + } + } + for (const item of targets) { + if (item.capture.snapshot.target.kind !== 'file') continue; + const moved = backupPublicRegularFileBinding( + item.previousBinding, + item.capture.commitPath, + item.backup, + { subject: 'previous-compare-target' }, + ); + item.backupPresent = moved.backupCreated === true; + item.backupVerified = moved.backupVerified === true; + if (item.backupPresent) backedUp.push(item); + if (moved.status === 'recovery-required' && moved.recoveryFile) { + item.backupRecovery = moved.recoveryFile; + } + if (moved.status !== 'backed-up') throw targetChanged(item, moved); + } + for (const item of targets) { + const candidate = verifyRegularFileBinding(item.candidateBinding, { + filePath: item.candidate, + expectedLinks: 1, + }); + if (candidate.status !== 'match') throw targetChanged(item, candidate); + try { + // linkSync is the no-clobber publish primitive: unlike renameSync, it + // fails with EEXIST if another writer claims an absent slot after the + // final snapshot verification. + fs.linkSync(item.candidate, item.capture.commitPath); + } catch (error) { + if (error?.code === 'EEXIST') { + throw targetChanged( + item, + 'target-claimed-during-publish', + ); + } + throw error; + } + item.committedIdentity = item.candidateIdentity; + committed.push(item); + const linked = verifyRegularFileBinding(item.candidateBinding, { + filePath: item.capture.commitPath, + expectedLinks: 2, + }); + if (linked.status !== 'match') throw targetChanged(item, linked); + } + for (const item of committed) { + const current = verifyRegularFileBinding(item.candidateBinding, { + filePath: item.capture.commitPath, + expectedLinks: 2, + }); + if (current.status !== 'match') throw targetChanged(item, current); + } + // The target remains the durable link. Removing the staging links inside + // the transaction keeps successful outputs at nlink=1 even if the outer + // best-effort staging-directory cleanup later fails. + for (const item of committed) { + const staged = verifyRegularFileBinding(item.candidateBinding, { + filePath: item.candidate, + expectedLinks: 2, + }); + if (staged.status !== 'match') throw targetChanged(item, staged); + const retired = quarantineRemoveRegularFileBinding( + item.candidateBinding, + item.candidate, + { subject: 'compare-candidate', expectedLinks: 2 }, + ); + if (retired.status !== 'removed') throw targetChanged(item, retired); + item.candidatePresent = false; + } + for (const item of committed) { + const finalized = verifyRegularFileBinding(item.candidateBinding, { + filePath: item.capture.commitPath, + expectedLinks: 1, + }); + if (finalized.status !== 'match') throw targetChanged(item, finalized); + } + retainSuccessfulBackupBindings = true; + return { + ownedBackupEntries: backedUp + .filter((item) => item.backupPresent) + .map((item) => ({ + filePath: item.backup, + binding: item.previousBinding, + subject: 'previous-compare-backup', + expectedLinks: 1, + })), + }; + } catch (cause) { + const rollbackErrors = []; + const recoveryFiles = targets + .filter((item) => item.backupRecovery) + .map((item) => ({ + backup: item.backupRecovery, + target: item.capture.commitPath, + verifiedPrevious: false, + })); + for (const item of [...committed].reverse()) { + try { + const removed = quarantineRemoveRegularFileBinding( + item.candidateBinding, + item.capture.commitPath, + { + subject: 'published-compare-target', + expectedLinks: item.candidatePresent ? 2 : 1, + }, + ); + if (removed.status !== 'removed') { + if (removed.status === 'recovery-required') { + recoveryFiles.push({ + backup: removed.recoveryFile, + target: item.capture.commitPath, + }); + } + throw new Error(`the committed target could not be removed safely (${removed.reason?.code || 'unknown target state'})`); + } + } catch (error) { + rollbackErrors.push(`${item.label}: remove failed (${error.message})`); + } + } + for (const item of [...backedUp].reverse()) { + try { + if (pathEntryExists(item.capture.commitPath)) { + const current = item.candidateBinding + ? verifyRegularFileBinding(item.candidateBinding, { + filePath: item.capture.commitPath, + expectedLinks: item.candidatePresent ? 2 : 1, + }) + : { status: 'different' }; + if (current.status !== 'match') { + throw new Error(`a changed target claimant prevents restoring the previous file (${current.reason?.code || 'unknown target state'})`); + } + const removed = quarantineRemoveRegularFileBinding( + item.candidateBinding, + item.capture.commitPath, + { + subject: 'published-compare-target', + expectedLinks: item.candidatePresent ? 2 : 1, + }, + ); + if (removed.status !== 'removed') { + if (removed.status === 'recovery-required') { + recoveryFiles.push({ + backup: removed.recoveryFile, + target: item.capture.commitPath, + }); + } + throw new Error(`the committed target could not be removed safely (${removed.reason?.code || 'unknown target state'})`); + } + } + if (item.backupVerified !== true) { + throw new Error('the backup entry was never verified as the previous file'); + } + const currentBackup = verifyRegularFileBinding(item.previousBinding, { + filePath: item.backup, + expectedLinks: 1, + }); + if (currentBackup.status !== 'match') { + item.backupVerified = false; + throw new Error('the recoverable backup was replaced before rollback'); + } + // Publish the previous file without clobbering a claimant that appears + // after the absence check. The backup remains the recovery source until + // the restored hard link has been identity-verified. + fs.linkSync(item.backup, item.capture.commitPath); + for (const restoredPath of [item.capture.commitPath, item.backup]) { + const restored = verifyRegularFileBinding(item.previousBinding, { + filePath: restoredPath, + expectedLinks: 2, + }); + if (restored.status !== 'match') { + throw new Error('the restored target identity could not be verified'); + } + } + const retired = quarantineRemoveRegularFileBinding( + item.previousBinding, + item.backup, + { subject: 'previous-compare-target', expectedLinks: 2 }, + ); + if (retired.status !== 'removed') { + throw new Error(`the restored backup could not be retired safely (${retired.reason?.code || 'unknown target state'})`); + } + item.backupPresent = false; + const finalized = verifyRegularFileBinding(item.previousBinding, { + filePath: item.capture.commitPath, + expectedLinks: 1, + }); + if (finalized.status !== 'match') { + throw new Error('the restored target changed before rollback finalized'); + } + } catch (error) { + rollbackErrors.push(`${item.label}: restore failed (${error.message})`); + // Track failed restoration rather than probing existence: a permission + // error must not make cleanup discard a potentially recoverable backup. + if (item.backupPresent !== false) { + recoveryFiles.push({ + backup: item.backup, + target: item.capture.commitPath, + verifiedPrevious: item.backupVerified === true, + }); + } + } + } + if (!rollbackErrors.length && cause.compareFailure) { + throw compareCommitError( + cause.message, + cause.compareFailure.code, + { + ...cause.compareFailure.details, + reason: cause.message, + }, + ); + } + throw compareCommitError( + (rollbackErrors.length + ? 'Architecture Delta pair commit failed and its previous files could not be fully restored.' + : 'Architecture Delta pair commit failed; the previous files were restored.') + + (recoveryFiles.length + ? ` Recovery directory retained at ${path.dirname(recoveryFiles[0].backup)}.` + : ''), + rollbackErrors.length ? 'delta/commit-rollback-failed' : 'delta/commit-failed', + { + reason: cause.message, + ...(rollbackErrors.length ? { rollbackErrors } : {}), + ...(recoveryFiles.length ? { + recoveryDirectory: path.dirname(recoveryFiles[0].backup), + recoveryDirectories: [...new Set(recoveryFiles.map(({ backup }) => path.dirname(backup)))], + recoveryFiles, + } : {}), + supportedFixes: recoveryFiles.length + ? [ + ...recoveryFiles.map(({ backup, target, verifiedPrevious }) => verifiedPrevious + ? `resolve the filesystem error, inspect the current target, then restore ${JSON.stringify(backup)} to ${JSON.stringify(target)} before retrying` + : `preserve and inspect the unverified entry at ${JSON.stringify(backup)}; do not treat it as the previous file for ${JSON.stringify(target)}`), + 'remove the recovery directory only after the previous files have been recovered and verified', + ] + : ['check that both output paths are writable regular files, then retry'], + }, + ); + } finally { + for (const item of targets) { + if (item.candidateBinding) releaseRegularFileBinding(item.candidateBinding); + if (item.previousBinding + && !(retainSuccessfulBackupBindings && item.backupPresent)) { + releaseRegularFileBinding(item.previousBinding); + } + } + } +} + +function commitDeliveryPair({ + htmlCandidate, + htmlContent, + provenanceCandidate, + provenanceContent, + stagingDirectory, + ownership, + verifyAtomicOutput, + fileBindingRuntime, +}) { + const { + backupPublicRegularFileBinding, + captureRegularFileBinding, + quarantineRemoveRegularFileBinding, + verifyRegularFileBinding, + releaseRegularFileBinding, + } = fileBindingRuntime || {}; + const state = assertDeliveryOwnership(ownership, 'start the delivery pair commit', { pending: 'owned' }); + const snapshots = state.deliveryTargets; + if (!snapshots?.artifact || !snapshots?.provenance) { + throw deliveryTargetStateError(state.output, 'delivery pair', { + status: 'unknown', + reason: { code: 'target-snapshot-missing' }, + }); + } + const targets = [ + { + label: 'HTML artifact', + target: snapshots.artifact.commitPath, + candidate: htmlCandidate, + content: htmlContent, + backup: path.join(stagingDirectory, '.previous-output'), + capture: snapshots.artifact, + }, + { + label: 'delivery provenance', + target: snapshots.provenance.commitPath, + candidate: provenanceCandidate, + content: provenanceContent, + backup: path.join(stagingDirectory, '.previous-provenance'), + capture: snapshots.provenance, + }, + ]; + const backedUp = []; + const retainedBackups = new Set(); + const publicRecoveryBackups = []; + let retainSuccessfulBackupBindings = false; + const committed = []; + try { + if (typeof backupPublicRegularFileBinding !== 'function' + || typeof captureRegularFileBinding !== 'function' + || typeof quarantineRemoveRegularFileBinding !== 'function' + || typeof verifyRegularFileBinding !== 'function' + || typeof releaseRegularFileBinding !== 'function') { + throw deliveryTargetStateError(state.output, 'delivery pair', { + status: 'unknown', + reason: { code: 'atomic-output-runtime-unavailable' }, + }); + } + for (const item of targets) { + const verification = verifyAtomicOutput(item.capture.snapshot); + if (verification.status !== 'match') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, verification); + } + if (item.capture.mode !== null) fs.chmodSync(item.candidate, item.capture.mode); + const candidate = captureRegularFileBinding(item.candidate, { + subject: 'delivery-candidate', + expectedSha256: item.content.sha256, + expectedBytes: item.content.bytes, + ...(item.capture.mode === null ? {} : { expectedMode: item.capture.mode }), + expectedLinks: 1, + }); + if (candidate.status !== 'captured') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, candidate); + } + item.candidateBinding = candidate.binding; + item.candidatePresent = true; + item.candidateIdentity = { + dev: candidate.identity.device, + ino: candidate.identity.inode, + }; + item.candidateMode = candidate.mode; + if (item.capture.snapshot.target.kind === 'file') { + const expected = item.capture.snapshot.target; + const previous = captureRegularFileBinding(item.target, { + subject: 'previous-delivery-target', + expectedIdentity: { device: expected.device, inode: expected.inode }, + expectedMode: expected.mode, + expectedLinks: 1, + }); + if (previous.status !== 'captured') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, previous); + } + item.previousBinding = previous.binding; + } + } + for (const item of targets) { + if (item.capture.snapshot.target.kind !== 'file') continue; + assertDeliveryOwnership(ownership, `back up the ${item.label}`, { pending: 'owned' }); + const moved = backupPublicRegularFileBinding( + item.previousBinding, + item.target, + item.backup, + { subject: 'previous-delivery-target' }, + ); + item.backupPresent = moved.backupCreated === true; + item.backupVerified = moved.backupVerified === true; + if (item.backupPresent) backedUp.push(item); + if (moved.status === 'recovery-required' && moved.recoveryFile) { + publicRecoveryBackups.push({ + label: `preserved replacement for ${item.label}`, + path: moved.recoveryFile, + target: item.target, + }); + } + if (moved.status !== 'backed-up') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, moved); + } + retainedBackups.add(item); + } + for (const item of targets) { + assertDeliveryOwnership(ownership, `commit the ${item.label}`, { pending: 'owned' }); + const candidate = verifyRegularFileBinding(item.candidateBinding, { + filePath: item.candidate, + expectedLinks: 1, + }); + if (candidate.status !== 'match') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, candidate); + } + try { + fs.linkSync(item.candidate, item.target); + } catch (error) { + if (error?.code === 'EEXIST') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, { + status: 'different', + reason: { code: 'target-claimed-during-publish' }, + }); + } + throw error; + } + item.committedIdentity = item.candidateIdentity; + committed.push(item); + const linked = verifyRegularFileBinding(item.candidateBinding, { + filePath: item.target, + expectedLinks: 2, + }); + if (linked.status !== 'match') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, linked); + } + } + for (const item of committed) { + assertDeliveryOwnership(ownership, `verify the committed ${item.label}`, { pending: 'owned' }); + const current = verifyRegularFileBinding(item.candidateBinding, { + filePath: item.target, + expectedLinks: 2, + }); + if (current.status !== 'match') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, current); + } + } + for (const item of committed) { + const staged = verifyRegularFileBinding(item.candidateBinding, { + filePath: item.candidate, + expectedLinks: 2, + }); + if (staged.status !== 'match') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, staged); + } + const retired = quarantineRemoveRegularFileBinding( + item.candidateBinding, + item.candidate, + { subject: 'delivery-candidate', expectedLinks: 2 }, + ); + if (retired.status !== 'removed') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, retired); + } + item.candidatePresent = false; + } + for (const item of committed) { + const finalized = verifyRegularFileBinding(item.candidateBinding, { + filePath: item.target, + expectedLinks: 1, + }); + if (finalized.status !== 'match') { + throw deliveryTargetStateError(item.capture.requestedPath, item.label, finalized); + } + } + // Finalization is part of the commit: keep the old files recoverable until + // the journal has been removed and checkers can accept the new artifact. + assertDeliveryOwnership(ownership, 'finalize the delivery journal', { pending: 'owned' }); + quarantineRemoveOwnedDeliveryEntry(state, { + filePath: state.pendingPath, + identity: state.pendingIdentity, + content: state.pendingContent, + subject: 'delivery-journal', + operation: 'finalize the delivery journal', + evidenceKey: 'journal', + }); + assertDeliveryOwnership(ownership, 'finish finalizing the delivery journal', { pending: 'absent' }); + state.pendingIdentity = undefined; + state.pendingContent = undefined; + state.phase = 'finalized'; + retainSuccessfulBackupBindings = true; + return { + recoverableBackups: [ + ...[...retainedBackups].map((item) => ({ label: item.label, path: item.backup })), + ...publicRecoveryBackups, + ], + ownedBackupEntries: backedUp + .filter((item) => item.backupPresent) + .map((item) => ({ + filePath: item.backup, + binding: item.previousBinding, + subject: 'previous-delivery-backup', + expectedLinks: 1, + })), + }; + } catch (cause) { + const recoveryDetails = () => ({ + recoveryRequired: true, + recoveryDirectory: stagingDirectory, + recoverableBackups: [ + ...[...retainedBackups].map((item) => ({ label: item.label, path: item.backup })), + ...publicRecoveryBackups, + ], + }); + if (cause.deliveryOwnershipCode === 'delivery/ownership-lost') { + cause.deliveryCommitDetails = { ...(cause.deliveryCommitDetails || {}), ...recoveryDetails() }; + throw cause; + } + const rollbackErrors = []; + const recoverableBackups = []; + try { + for (const item of [...committed].reverse()) { + try { + assertDeliveryOwnership(ownership, `roll back the ${item.label}`, { pending: 'identity' }); + const removed = quarantineRemoveRegularFileBinding(item.candidateBinding, item.target, { + subject: 'published-delivery-target', + expectedLinks: item.candidatePresent ? 2 : 1, + }); + if (removed.status !== 'removed') { + if (removed.status === 'recovery-required') { + publicRecoveryBackups.push({ + label: `preserved replacement for ${item.label}`, + path: removed.recoveryFile, + target: item.target, + }); + } + throw new Error(`the committed target could not be removed safely (${removed.reason?.code || 'unknown target state'})`); + } + } catch (error) { + if (error.deliveryOwnershipCode === 'delivery/ownership-lost') throw error; + rollbackErrors.push(`${item.label}: remove failed (${error.message})`); + } + } + for (const item of [...backedUp].reverse()) { + try { + assertDeliveryOwnership(ownership, `restore the previous ${item.label}`, { pending: 'identity' }); + if (pathEntryExists(item.target)) { + const current = item.candidateBinding + ? verifyRegularFileBinding(item.candidateBinding, { + filePath: item.target, + expectedLinks: item.candidatePresent ? 2 : 1, + }) + : { status: 'different' }; + if (current.status !== 'match') { + throw new Error(`a changed target claimant prevents restoring the previous file (${current.reason?.code || 'unknown target state'})`); + } + const removed = quarantineRemoveRegularFileBinding(item.candidateBinding, item.target, { + subject: 'published-delivery-target', + expectedLinks: item.candidatePresent ? 2 : 1, + }); + if (removed.status !== 'removed') { + if (removed.status === 'recovery-required') { + publicRecoveryBackups.push({ + label: `preserved replacement for ${item.label}`, + path: removed.recoveryFile, + target: item.target, + }); + } + throw new Error(`the committed target could not be removed safely (${removed.reason?.code || 'unknown target state'})`); + } + } + assertDeliveryOwnership(ownership, `restore the previous ${item.label}`, { pending: 'identity' }); + if (item.backupVerified !== true) { + throw new Error('the backup entry was never verified as the previous delivery file'); + } + const currentBackup = verifyRegularFileBinding(item.previousBinding, { + filePath: item.backup, + expectedLinks: 1, + }); + if (currentBackup.status !== 'match') { + item.backupVerified = false; + retainedBackups.delete(item); + throw new Error('the previous delivery backup identity changed'); + } + try { + fs.linkSync(item.backup, item.target); + } catch (error) { + if (error?.code === 'EEXIST') { + throw new Error('a new target claimant prevents restoring the previous file'); + } + throw error; + } + for (const restoredPath of [item.target, item.backup]) { + const restored = verifyRegularFileBinding(item.previousBinding, { + filePath: restoredPath, + expectedLinks: 2, + }); + if (restored.status !== 'match') { + throw new Error('the restored delivery target identity could not be verified'); + } + } + const retired = quarantineRemoveRegularFileBinding( + item.previousBinding, + item.backup, + { subject: 'previous-delivery-target', expectedLinks: 2 }, + ); + if (retired.status !== 'removed') { + throw new Error(`the restored delivery backup could not be retired safely (${retired.reason?.code || 'unknown target state'})`); + } + item.backupPresent = false; + const restoredFinal = verifyRegularFileBinding(item.previousBinding, { + filePath: item.target, + expectedLinks: 1, + }); + if (restoredFinal.status !== 'match') { + throw new Error('the restored delivery target link count could not be verified'); + } + retainedBackups.delete(item); + } catch (error) { + if (error.deliveryOwnershipCode === 'delivery/ownership-lost') throw error; + rollbackErrors.push(`${item.label}: restore failed (${error.message})`); + // A failed restoration leaves this exact backup at its source path. + // Record it without probing the failing recovery path again. + if (item.backupPresent !== false) { + recoverableBackups.push({ + label: item.backupVerified === true + ? item.label + : `unverified preserved backup entry for ${item.label}`, + path: item.backup, + target: item.target, + verifiedPrevious: item.backupVerified === true, + }); + } + } + } + assertDeliveryOwnership(ownership, 'finish the delivery rollback', { pending: 'identity' }); + } catch (rollbackError) { + if (rollbackError.deliveryOwnershipCode === 'delivery/ownership-lost') { + rollbackError.deliveryCommitDetails = { + ...(rollbackError.deliveryCommitDetails || {}), + commitError: { + reason: cause.message, + ...(cause?.code ? { systemCode: cause.code } : {}), + }, + ...recoveryDetails(), + }; + throw rollbackError; + } + rollbackErrors.push(rollbackError.message); + } + const error = new Error(rollbackErrors.length + ? 'Delivery pair commit failed and its previous files could not be fully restored.' + : 'Delivery pair commit failed; the previous files were restored.'); + error.deliveryCommitDetails = { + reason: cause.message, + ...(rollbackErrors.length ? { + rollbackErrors, + recoveryRequired: true, + recoveryDirectory: stagingDirectory, + recoverableBackups: [...recoverableBackups, ...publicRecoveryBackups], + } : {}), + }; + if (cause.deliveryTargetState) { + error.deliveryTargetState = cause.deliveryTargetState; + error.archifyDiagnostics = cause.archifyDiagnostics; + } + throw error; + } finally { + for (const item of targets) { + if (item.candidateBinding) releaseRegularFileBinding(item.candidateBinding); + if (item.previousBinding + && !(retainSuccessfulBackupBindings && item.backupPresent)) { + releaseRegularFileBinding(item.previousBinding); + } + } + } +} + +function renderValidatedArchitecture(inputPath, outputPath, quality, repoRoot, captureStagingFile) { + const render = runNode([rendererPath('architecture'), inputPath, outputPath], { + stdio: 'pipe', + env: rendererEnv(quality, repoRoot, true), + }); + if (render.status !== 0) { + const failure = rendererFailure(render); + const error = new Error(failure.error); + error.compareStage = 'input'; + error.compareStatus = render.status ?? 1; + error.diagnostics = failure.diagnostics; + throw error; + } + let artifact; + if (captureStagingFile) { + artifact = fs.readFileSync(outputPath); + captureStagingFile(outputPath, artifactIdentity(artifact)); + } + const checkMaxBuffer = artifactCheckMaxBuffer(); + const check = runNode([path.join(skillRoot, 'scripts/check-render-output.mjs'), outputPath], { + stdio: 'pipe', + maxBuffer: checkMaxBuffer, + }); + if (check.status !== 0) { + const error = new Error('Validated snapshot failed final artifact checks.'); + error.compareStage = 'check'; + error.compareStatus = check.status ?? 1; + const outputLimit = checkerOutputLimitDiagnostics(check, checkMaxBuffer); + if (outputLimit) { + error.diagnostics = outputLimit; + } else { + try { + error.checker = JSON.parse(check.stdout); + error.diagnostics = checkerDiagnostics(error.checker); + } catch { + error.diagnostics = []; + } + } + throw error; + } + artifact ||= fs.readFileSync(outputPath); + return { + artifact, + html: artifact.toString('utf8'), + checks: JSON.parse(check.stdout), + sourceEvidence: sourceEvidenceFromArtifact(artifact), + }; +} + +async function commandCompare(args) { + await loadSidecarPathRuntime(); + const { + canonicalFuturePath, + resolveOutputPath, + validateAuthoredOutputPath, + } = await import('../renderers/shared/output-path.mjs'); + const { + captureAtomicOutput, + verifyAtomicOutput, + backupPublicRegularFileBinding, + captureRegularFileBinding, + quarantineRemoveRegularFileBinding, + verifyRegularFileBinding, + releaseRegularFileBinding, + } = await import('../renderers/shared/atomic-output.mjs'); + const fileBindingRuntime = { + backupPublicRegularFileBinding, + captureRegularFileBinding, + quarantineRemoveRegularFileBinding, + verifyRegularFileBinding, + releaseRegularFileBinding, + }; + const { + sameParent, + sidecarNamespaceComponentKey, + } = await import('../renderers/shared/path-semantics.mjs'); + sidecarNamespaceComponentKeyRuntime = sidecarNamespaceComponentKey; + const qualityArgs = extractQualityArgs(args); + const repoArgs = extractRepoRootArgs(qualityArgs.rest); + const options = extractCompareOptions(repoArgs.rest); + const [type, baseInput, headInput, requestedOutput] = options.positional; + if (type !== 'architecture' || !baseInput || !headInput || options.positional.length > 4) fail(usage()); + let deltaRuntime; + try { + deltaRuntime = await import(pathToFileURL(path.join(skillRoot, 'delta/architecture-delta.mjs')).href); + } catch (error) { + reportCompareFailure({ json: options.json, stage: 'prepare', error: 'Architecture compare runtime is unavailable.', code: 'delta/runtime-missing', details: { reason: error.message, supportedFixes: ['install the complete Archify skill package'] } }); + return; + } + const { + ArchitectureDeltaError, + annotateArchitectureSideSvg, + buildDeltaSvg, + canonicalArchitecture, + canonicalArchitectureJson, + compareArchitecture, + extractArchitectureSvg, + extractArtifactCss, + renderArchitectureDeltaHtml, + validateArchitectureDeltaHtml, + } = deltaRuntime; + + const basePath = path.resolve(baseInput); + const headPath = path.resolve(headInput); + const explicitReceiptTarget = options.receipt ? path.resolve(options.receipt) : undefined; + const rawOutputTarget = requestedOutput || 'architecture-delta.html'; + const rawOutputExtension = path.extname(rawOutputTarget); + // Preserve the established alias-first diagnostic for an already-invalid + // CLI extension without letting this legacy spelling select the real + // sidecar namespace for any valid compare output. + const invalidOutputAliasPreflight = !explicitReceiptTarget + && rawOutputExtension.toLowerCase() !== '.html' + ? path.resolve(rawOutputExtension + ? `${rawOutputTarget.slice(0, -rawOutputExtension.length)}.receipt.json` + : `${rawOutputTarget}.receipt.json`) + : undefined; + let outputPath; + try { + ({ outputPath } = resolveOutputPath({ + requestedOutput, + defaultOutput: 'architecture-delta.html', + inputPaths: [basePath, headPath], + otherOutputPaths: explicitReceiptTarget + ? [explicitReceiptTarget] + : (invalidOutputAliasPreflight ? [invalidOutputAliasPreflight] : []), + })); + } catch (error) { + const outputDiagnostic = error.archifyDiagnostics?.[0]; + reportCompareFailure({ + json: options.json, + stage: 'prepare', + error: error.message, + code: outputDiagnostic?.code || 'output/path-resolution', + details: { + ...(outputDiagnostic?.subject || {}), + ...(outputDiagnostic?.evidence || {}), + supportedFixes: outputDiagnostic?.supportedFixes || ['choose a safe output path and retry'], + }, + }); + return; + } + const requestedOutputPath = outputPath; + let receiptPath; + let requestedReceiptPath; + if (explicitReceiptTarget) { + try { + ({ outputPath: receiptPath } = resolveOutputPath({ + requestedOutput: options.receipt, + defaultOutput: options.receipt, + requiredExtension: '.json', + inputPaths: [basePath, headPath], + otherOutputPaths: [outputPath], + })); + requestedReceiptPath = receiptPath; + } catch (error) { + const outputDiagnostic = error.archifyDiagnostics?.[0]; + reportCompareFailure({ + json: options.json, + stage: 'prepare', + error: error.message, + code: outputDiagnostic?.code || 'output/path-resolution', + details: { + ...(outputDiagnostic?.subject || {}), + ...(outputDiagnostic?.evidence || {}), + supportedFixes: outputDiagnostic?.supportedFixes || ['choose a safe receipt path and retry'], + }, + }); + return; + } + } + try { + outputPath = canonicalFuturePath(requestedOutputPath); + if (requestedReceiptPath) receiptPath = canonicalFuturePath(requestedReceiptPath); + } catch (error) { + const outputDiagnostic = error.archifyDiagnostics?.[0]; + reportCompareFailure({ + json: options.json, + stage: 'prepare', + error: error.message, + code: outputDiagnostic?.code || 'output/path-resolution', + details: { + ...(outputDiagnostic?.subject || {}), + ...(outputDiagnostic?.evidence || {}), + supportedFixes: outputDiagnostic?.supportedFixes || ['choose safe compare output paths and retry'], + }, + }); + return; + } + let baseBuffer; + let headBuffer; + let base; + let head; + try { + baseBuffer = fs.readFileSync(basePath); + base = JSON.parse(baseBuffer.toString('utf8')); + } catch (error) { + reportCompareFailure({ json: options.json, stage: 'input', error: `Could not read base input: ${error.message}`, code: 'delta/base-input', details: { side: 'base', reason: error.message } }); + return; + } + try { + headBuffer = fs.readFileSync(headPath); + head = JSON.parse(headBuffer.toString('utf8')); + } catch (error) { + reportCompareFailure({ json: options.json, stage: 'input', error: `Could not read head input: ${error.message}`, code: 'delta/head-input', details: { side: 'head', reason: error.message } }); + return; + } + + for (const [side, document] of [['base', base], ['head', head]]) { + try { + validateAuthoredOutputPath(document?.meta?.output); + } catch (error) { + const diagnosticEntry = error.archifyDiagnostics?.[0]; + reportCompareFailure({ + json: options.json, + stage: 'input', + error: `${side === 'base' ? 'Base' : 'Head'} snapshot failed validation: ${error.message}`, + code: diagnosticEntry?.code || `delta/${side}-validation`, + details: { + side, + ...(diagnosticEntry?.subject?.path ? { path: diagnosticEntry.subject.path } : {}), + ...(diagnosticEntry?.evidence || {}), + supportedFixes: diagnosticEntry?.supportedFixes || [], + }, + }); + return; + } + } + + let outputDirectory = path.dirname(outputPath); + if (receiptPath) { + const explicitReceiptDirectoryIdentity = sameParent(outputPath, receiptPath); + if (explicitReceiptDirectoryIdentity.status === 'unknown') { + reportCompareFailure({ + json: options.json, + stage: 'prepare', + error: 'The compare receipt directory identity could not be verified.', + code: 'delta/receipt-directory-identity-indeterminate', + details: { + artifactDirectory: outputDirectory, + receiptDirectory: path.dirname(receiptPath), + pathIdentity: explicitReceiptDirectoryIdentity.reason, + supportedFixes: ['restore access to both output directories or choose paths in one verifiable directory'], + }, + }); + return; + } + if (explicitReceiptDirectoryIdentity.status !== 'match') { + reportCompareFailure({ + json: options.json, + stage: 'prepare', + error: 'The compare receipt must be written beside the HTML artifact.', + code: 'delta/receipt-directory', + details: { + artifactDirectory: outputDirectory, + receiptDirectory: path.dirname(receiptPath), + pathIdentity: explicitReceiptDirectoryIdentity.reason, + supportedFixes: ['choose a --receipt path in the same directory as output.html'], + }, + }); + return; + } + } + try { + fs.mkdirSync(outputDirectory, { recursive: true }); + } catch (error) { + reportCompareFailure({ json: options.json, stage: 'prepare', error: `Could not create compare output directory: ${error.message}`, code: 'delta/output-directory', details: { reason: error.message } }); + return; + } + + if (!receiptPath) { + try { + const defaultReceiptPath = compareReceiptPath(outputPath); + ({ outputPath: receiptPath } = resolveOutputPath({ + requestedOutput: defaultReceiptPath, + defaultOutput: defaultReceiptPath, + requiredExtension: '.json', + inputPaths: [basePath, headPath], + otherOutputPaths: [outputPath], + })); + requestedReceiptPath = receiptPath; + receiptPath = canonicalFuturePath(requestedReceiptPath); + } catch (error) { + const outputDiagnostic = error.archifyDiagnostics?.[0]; + const namespaceIndeterminate = error?.code === 'ARCHIFY_SIDECAR_NAMESPACE_INDETERMINATE'; + reportCompareFailure({ + json: options.json, + stage: 'prepare', + error: error.message, + code: namespaceIndeterminate + ? 'delta/receipt-namespace-indeterminate' + : outputDiagnostic?.code || 'output/path-resolution', + details: { + ...(outputDiagnostic?.subject || {}), + ...(outputDiagnostic?.evidence || {}), + ...(namespaceIndeterminate ? { pathIdentity: error.sidecarNamespaceReason } : {}), + supportedFixes: outputDiagnostic?.supportedFixes + || ['choose a safe receipt path beside the compare artifact and retry'], + }, + }); + return; + } + } + + const receiptDirectoryIdentity = sameParent(outputPath, receiptPath); + if (receiptDirectoryIdentity.status === 'unknown') { + reportCompareFailure({ + json: options.json, + stage: 'prepare', + error: 'The compare receipt directory identity could not be verified.', + code: 'delta/receipt-directory-identity-indeterminate', + details: { + artifactDirectory: outputDirectory, + receiptDirectory: path.dirname(receiptPath), + pathIdentity: receiptDirectoryIdentity.reason, + supportedFixes: ['restore access to both output directories or choose paths in one verifiable directory'], + }, + }); + return; + } + if (receiptDirectoryIdentity.status !== 'match') { + reportCompareFailure({ + json: options.json, + stage: 'prepare', + error: 'The compare receipt must be written beside the HTML artifact.', + code: 'delta/receipt-directory', + details: { + artifactDirectory: outputDirectory, + receiptDirectory: path.dirname(receiptPath), + pathIdentity: receiptDirectoryIdentity.reason, + supportedFixes: ['choose a --receipt path in the same directory as output.html'], + }, + }); + return; + } + + const outputCapture = captureAtomicOutput(requestedOutputPath); + const receiptCapture = captureAtomicOutput(requestedReceiptPath); + for (const [capture, label, requestedPath] of [ + [outputCapture, 'HTML artifact', requestedOutputPath], + [receiptCapture, 'receipt', requestedReceiptPath], + ]) { + if (capture.status === 'captured') continue; + const failure = compareAtomicOutputFailure(capture, label, requestedPath, 'prepare'); + reportCompareFailure({ + json: options.json, + stage: failure.stage, + error: failure.message, + code: failure.code, + details: failure.details, + }); + return; + } + const outputParent = outputCapture.snapshot.slot; + const receiptParent = receiptCapture.snapshot.slot; + if (outputParent.parentDevice !== receiptParent.parentDevice + || outputParent.parentInode !== receiptParent.parentInode) { + reportCompareFailure({ + json: options.json, + stage: 'prepare', + error: 'The compare receipt must be written beside the HTML artifact.', + code: 'delta/receipt-directory', + details: { + artifactDirectory: outputParent.parentPath, + receiptDirectory: receiptParent.parentPath, + supportedFixes: ['choose a --receipt path in the same physical directory as output.html'], + }, + }); + return; + } + outputPath = outputCapture.commitPath; + receiptPath = receiptCapture.commitPath; + outputDirectory = path.dirname(outputPath); + + let stagingDirectory; + let stagingIdentity; + try { + const staging = createOwnedEmptyStagingDirectory( + path.join(outputDirectory, '.archify-compare-'), + ); + stagingDirectory = staging.directory; + stagingIdentity = staging.identity; + } catch (error) { + reportCompareFailure({ json: options.json, stage: 'prepare', error: `Could not create compare candidate: ${error.message}`, code: 'delta/candidate-directory', details: { reason: error.message } }); + return; + } + + const baseCandidate = path.join(stagingDirectory, 'base.html'); + const headCandidate = path.join(stagingDirectory, 'head.html'); + const rawBaseCandidate = path.join(stagingDirectory, 'base.raw.html'); + const rawHeadCandidate = path.join(stagingDirectory, 'head.raw.html'); + const rawBaseInput = path.join(stagingDirectory, 'base.snapshot.json'); + const rawHeadInput = path.join(stagingDirectory, 'head.snapshot.json'); + const canonicalBaseInput = path.join(stagingDirectory, 'base.architecture.json'); + const canonicalHeadInput = path.join(stagingDirectory, 'head.architecture.json'); + const htmlCandidate = path.join(stagingDirectory, path.basename(outputPath)); + const receiptCandidate = path.join(stagingDirectory, path.basename(receiptPath)); + let preserveRecoveryDirectory = false; + const stagingOwnership = []; + const captureCompareStagingFile = (filePath, content) => captureOwnedStagingFile( + stagingOwnership, + filePath, + fileBindingRuntime, + { subject: 'compare-staging-entry', content }, + ); + + try { + let baseResult; + let headResult; + for (const { side, snapshotPath, buffer } of [ + { side: 'base', snapshotPath: rawBaseInput, buffer: baseBuffer }, + { side: 'head', snapshotPath: rawHeadInput, buffer: headBuffer }, + ]) { + try { + fs.writeFileSync(snapshotPath, buffer, { flag: 'wx' }); + captureCompareStagingFile(snapshotPath, artifactIdentity(buffer)); + } catch (error) { + const message = `Could not freeze ${side} compare snapshot: ${error.message}`; + reportCompareFailure({ + json: options.json, + stage: 'prepare', + error: message, + code: 'delta/freeze-snapshot', + details: { + side, + ...(error?.code ? { systemCode: error.code } : {}), + reason: error.message, + supportedFixes: ['choose a writable compare output directory on the target filesystem'], + }, + }); + return; + } + } + try { + renderValidatedArchitecture( + rawBaseInput, + rawBaseCandidate, + qualityArgs.quality, + repoArgs.repoRoot, + captureCompareStagingFile, + ); + } catch (error) { + const diagnosticEntry = error.diagnostics?.[0]; + reportCompareFailure({ + json: options.json, + stage: error.compareStage || 'validate', + error: `Base snapshot failed validation: ${error.message}`, + code: diagnosticEntry?.code || 'delta/base-validation', + details: { side: 'base', ...(diagnosticEntry?.subject?.path ? { path: diagnosticEntry.subject.path } : {}), ...(diagnosticEntry?.evidence || {}), supportedFixes: diagnosticEntry?.supportedFixes || [] }, + status: error.compareStatus || 1, + }); + return; + } + try { + renderValidatedArchitecture( + rawHeadInput, + rawHeadCandidate, + qualityArgs.quality, + repoArgs.repoRoot, + captureCompareStagingFile, + ); + } catch (error) { + const diagnosticEntry = error.diagnostics?.[0]; + reportCompareFailure({ + json: options.json, + stage: error.compareStage || 'validate', + error: `Head snapshot failed validation: ${error.message}`, + code: diagnosticEntry?.code || 'delta/head-validation', + details: { side: 'head', ...(diagnosticEntry?.subject?.path ? { path: diagnosticEntry.subject.path } : {}), ...(diagnosticEntry?.evidence || {}), supportedFixes: diagnosticEntry?.supportedFixes || [] }, + status: error.compareStatus || 1, + }); + return; + } + + // Validation must see the exact authored inputs. Only after both sides + // pass do we canonicalize their collection order for deterministic SVG + // geometry and stable artifact bytes. + const canonicalBase = canonicalArchitecture(base); + const canonicalHead = canonicalArchitecture(head); + canonicalBase.meta.output = base.meta.output; + canonicalHead.meta.output = head.meta.output; + const canonicalBaseBytes = Buffer.from(JSON.stringify(canonicalBase)); + const canonicalHeadBytes = Buffer.from(JSON.stringify(canonicalHead)); + fs.writeFileSync(canonicalBaseInput, canonicalBaseBytes); + captureCompareStagingFile(canonicalBaseInput, artifactIdentity(canonicalBaseBytes)); + fs.writeFileSync(canonicalHeadInput, canonicalHeadBytes); + captureCompareStagingFile(canonicalHeadInput, artifactIdentity(canonicalHeadBytes)); + baseResult = renderValidatedArchitecture( + canonicalBaseInput, + baseCandidate, + qualityArgs.quality, + repoArgs.repoRoot, + captureCompareStagingFile, + ); + headResult = renderValidatedArchitecture( + canonicalHeadInput, + headCandidate, + qualityArgs.quality, + repoArgs.repoRoot, + captureCompareStagingFile, + ); + + const semanticHash = (diagram) => createHash('sha256').update(canonicalArchitectureJson(diagram)).digest('hex'); + let compareIr; + try { + compareIr = compareArchitecture(base, head, { + baseRawSha256: createHash('sha256').update(baseBuffer).digest('hex'), + headRawSha256: createHash('sha256').update(headBuffer).digest('hex'), + baseSemanticSha256: semanticHash(base), + headSemanticSha256: semanticHash(head), + baseBytes: baseBuffer.byteLength, + headBytes: headBuffer.byteLength, + baseVerified: Boolean(baseResult.sourceEvidence), + headVerified: Boolean(headResult.sourceEvidence), + }); + } catch (error) { + if (!(error instanceof ArchitectureDeltaError)) throw error; + reportCompareFailure({ json: options.json, stage: 'compare', error: error.message, code: error.code, details: error.details }); + return; + } + + const baseSourceSvg = extractArchitectureSvg(baseResult.html); + const headSourceSvg = extractArchitectureSvg(headResult.html); + const baseSvg = annotateArchitectureSideSvg(baseSourceSvg, compareIr, 'base'); + const headSvg = annotateArchitectureSideSvg(headSourceSvg, compareIr, 'head'); + const deltaSvg = buildDeltaSvg(baseSourceSvg, headSourceSvg, compareIr); + // Raw input hashes and byte counts belong in the sidecar receipt, not the + // artifact. Keeping them out makes formatting-only input rewrites produce + // the exact same canonical review HTML and artifact hash. + const artifactIr = { + ...compareIr, + base: Object.fromEntries(Object.entries(compareIr.base).filter(([key]) => !['rawSha256', 'bytes'].includes(key))), + head: Object.fromEntries(Object.entries(compareIr.head).filter(([key]) => !['rawSha256', 'bytes'].includes(key))), + }; + const html = renderArchitectureDeltaHtml({ + receipt: artifactIr, + baseSvg, + deltaSvg, + headSvg, + baseHtml: baseResult.html, + headHtml: headResult.html, + artifactCss: extractArtifactCss(headResult.html), + }); + const deltaValidation = validateArchitectureDeltaHtml(html, artifactIr); + fs.writeFileSync(htmlCandidate, html); + if (outputCapture.mode !== null) fs.chmodSync(htmlCandidate, outputCapture.mode); + const artifact = fs.readFileSync(htmlCandidate); + captureCompareStagingFile(htmlCandidate, artifactIdentity(artifact)); + const baseChecks = baseResult.checks.checks.filter((check) => check.ok).length; + const headChecks = headResult.checks.checks.filter((check) => check.ok).length; + const finalReceipt = { + ...compareIr, + artifact: artifactIdentity(artifact), + validation: { + checksPassed: baseChecks + headChecks + deltaValidation.checksPassed, + checkCount: baseResult.checks.checks.length + headResult.checks.checks.length + deltaValidation.checkCount, + baseComposition: baseResult.checks.composition.status, + headComposition: headResult.checks.composition.status, + }, + }; + const receiptBytes = Buffer.from(`${JSON.stringify(finalReceipt, null, 2)}\n`); + fs.writeFileSync(receiptCandidate, receiptBytes); + if (receiptCapture.mode !== null) fs.chmodSync(receiptCandidate, receiptCapture.mode); + captureCompareStagingFile(receiptCandidate, artifactIdentity(receiptBytes)); + + try { + const currentOutput = resolveOutputPath({ + requestedOutput, + defaultOutput: 'architecture-delta.html', + inputPaths: [basePath, headPath], + otherOutputPaths: [requestedReceiptPath], + }).outputPath; + resolveOutputPath({ + requestedOutput: options.receipt || compareReceiptPath(currentOutput), + defaultOutput: compareReceiptPath(currentOutput), + requiredExtension: '.json', + inputPaths: [basePath, headPath], + otherOutputPaths: [currentOutput], + }); + } catch (error) { + const outputDiagnostic = error.archifyDiagnostics?.[0]; + reportCompareFailure({ + json: options.json, + stage: 'commit', + error: error.message, + code: outputDiagnostic?.code || 'output/path-resolution', + details: { + ...(outputDiagnostic?.subject || {}), + ...(outputDiagnostic?.evidence || {}), + supportedFixes: outputDiagnostic?.supportedFixes || ['restore safe output paths and retry'], + }, + }); + return; + } + + const committed = commitComparePair({ + htmlCandidate, + htmlContent: finalReceipt.artifact, + receiptCandidate, + receiptContent: artifactIdentity(receiptBytes), + outputCapture, + receiptCapture, + stagingDirectory, + verifyAtomicOutput, + fileBindingRuntime, + }); + stagingOwnership.push(...committed.ownedBackupEntries); + if (options.json) console.log(JSON.stringify(finalReceipt, null, 2)); + else { + console.log(`compared architecture ${requestedOutputPath}`); + console.log(`${finalReceipt.validation.checksPassed}/${finalReceipt.validation.checkCount} checks; completeness ${finalReceipt.completeness}; ${finalReceipt.proofLevel}; sha256 ${finalReceipt.artifact.sha256.slice(0, 12)}`); + console.log(`receipt ${requestedReceiptPath}`); + } + } catch (error) { + if (error instanceof ArchitectureDeltaError) { + reportCompareFailure({ json: options.json, stage: 'artifact', error: error.message, code: error.code, details: error.details }); + } else if (error.compareStage === 'commit') { + preserveRecoveryDirectory = Boolean(error.compareDetails?.recoveryFiles?.length); + reportCompareFailure({ + json: options.json, + stage: error.compareStage, + error: error.message, + code: error.compareCode, + details: error.compareDetails, + }); + } else { + reportCompareFailure({ json: options.json, stage: 'internal', error: 'Architecture compare failed before commit.', code: 'delta/internal', details: { reason: error.message } }); + } + } finally { + try { + if (!preserveRecoveryDirectory) { + cleanupOwnedStagingDirectory( + stagingDirectory, + stagingIdentity, + stagingOwnership, + fileBindingRuntime, + ); + } else { + releaseOwnedStagingBindings(stagingOwnership, fileBindingRuntime); + } + } catch (error) { + console.error(`Warning: could not remove compare staging directory: ${error.message}`); + } + } +} + +function commandRender(args) { + const qualityArgs = extractQualityArgs(args); + const repoArgs = extractRepoRootArgs(qualityArgs.rest); + // render takes no options of its own once --quality and --repo-root are + // stripped, so anything left starting with -- is a typo. Without this a + // mistyped flag was taken as the output path: `render architecture spec.json + // --json out.html` wrote a file literally named `--json` and never wrote + // out.html, exiting 0. Every sibling subcommand already guards this. + const unknown = repoArgs.rest.filter((arg) => arg.startsWith('--')); + if (unknown.length) fail(`Unknown render option "${unknown[0]}".`); + const [type, input, output] = repoArgs.rest; + if (!type || !input || repoArgs.rest.length > 3) fail(usage()); + const result = runNode([rendererPath(type), input, ...(output ? [output] : [])], { + env: rendererEnv(qualityArgs.quality, repoArgs.repoRoot), + }); + if (result.status !== 0) exitFrom(result); +} + +function reportArtifactFailure({ command, json, stage, type, input, output, error, diagnostics = [], status = 1, checker, receiptId, provenance, update }) { + const receipt = { + schemaVersion: 1, + ok: false, + command, + ...(receiptId ? { receiptId } : {}), + stage, + type, + input, + ...(output === undefined ? {} : { output }), + error, + diagnostics, + ...(provenance ? { provenance } : {}), + ...(checker ? { checker } : {}), + ...(update ? { update } : {}), + }; + if (json) console.log(JSON.stringify(receipt, null, 2)); + else { + console.error(formatDiagnostics(error, diagnostics)); + if (update?.noticeRequired) console.log(update.noticeText); + } + process.exitCode = status; +} + +function writeDeliveryFailureReceipt(options) { + const receiptId = options.receiptId || randomUUID(); + const recorded = recordDeliveryFailure({ ...options, receiptId }); + const ownershipOrLockError = recorded.ownershipError || recorded.lockError; + if (ownershipOrLockError) { + const failureWasRecorded = recorded.status && recorded.status !== 'unrecorded'; + const lockOrOwnershipFailure = Boolean( + ownershipOrLockError.deliveryLockCode + || ownershipOrLockError.deliveryOwnershipCode, + ); + reportArtifactFailure({ + ...options, + command: 'deliver', + receiptId, + status: 1, + error: lockOrOwnershipFailure + ? `Could not safely continue delivery for "${options.output}": ${ownershipOrLockError.message}` + : options.error, + ...(failureWasRecorded ? { provenance: recorded.status } : {}), + diagnostics: lockOrOwnershipFailure + ? [ + deliveryLockFailureDiagnostic(options.output, ownershipOrLockError), + ...(options.diagnostics || []), + ...(recorded.diagnostic ? [recorded.diagnostic] : []), + ] + : [ + ...(options.diagnostics || []), + deliveryLockFailureDiagnostic(options.output, ownershipOrLockError), + ...(recorded.diagnostic ? [recorded.diagnostic] : []), + ], + }); + return recorded; + } + const recoveryDiagnosticFirst = Boolean(recorded.journalRecovery || recorded.provenanceRecovery); + const diagnostics = recoveryDiagnosticFirst + ? [ + ...(recorded.diagnostic ? [recorded.diagnostic] : []), + ...(options.diagnostics || []), + ] + : [ + ...(options.diagnostics || []), + ...(recorded.diagnostic ? [recorded.diagnostic] : []), + ]; + reportArtifactFailure({ + ...options, + command: 'deliver', + receiptId, + provenance: recorded.status, + diagnostics, + }); + return recorded; +} + +function reportArtifactArgumentFailure(command, error) { + const details = error.archifyArgument || {}; + reportArtifactFailure({ + command, + json: true, + stage: 'arguments', + error: error.message, + diagnostics: [diagnostic({ + code: details.code || 'cli/invalid-arguments', + message: error.message, + subject: { command, ...(details.subject || {}) }, + evidence: details.evidence || {}, + supportedFixes: details.supportedFixes || ['correct the command arguments and retry'], + })], + status: 2, + }); +} + +function sourceEvidenceFromArtifact(artifact) { + const html = artifact.toString('utf8'); + const match = html.match(/ + +`; +} + +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(/ { resolveClosed = resolve; }); + + function publicState() { + return JSON.parse(JSON.stringify(state)); + } + + function sendState(res) { + res.write(`event: state\ndata: ${safeJson(publicState())}\n\n`); + } + + function broadcast() { + for (const res of clients) sendState(res); + } + + const page = Buffer.from(previewPage()); + const server = http.createServer((req, res) => { + const expectedHost = `${loopbackHost}:${port}`; + if (req.headers.host !== expectedHost) { + res.writeHead(403, responseHeaders('text/plain; charset=utf-8')); + res.end('Forbidden host'); + return; + } + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405, { ...responseHeaders('text/plain; charset=utf-8'), Allow: 'GET, HEAD' }); + res.end('Method not allowed'); + return; + } + + let url; + try { + url = new URL(req.url, `http://${expectedHost}`); + } catch { + res.writeHead(400, responseHeaders('text/plain; charset=utf-8')); + res.end('Bad request'); + return; + } + + if (url.pathname === '/') { + res.writeHead(200, { + ...responseHeaders('text/html; charset=utf-8'), + 'Content-Security-Policy': "default-src 'none'; frame-src 'self'; connect-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'", + 'Content-Length': page.byteLength, + }); + if (req.method === 'HEAD') res.end(); + else res.end(page); + return; + } + if (url.pathname === '/state') { + const body = Buffer.from(`${safeJson(publicState())}\n`); + res.writeHead(200, { ...responseHeaders('application/json; charset=utf-8'), 'Content-Length': body.byteLength }); + if (req.method === 'HEAD') res.end(); + else res.end(body); + return; + } + if (url.pathname === '/artifact.html') { + if (!artifactBuffer) { + res.writeHead(404, responseHeaders('text/plain; charset=utf-8')); + res.end('No verified artifact yet'); + return; + } + res.writeHead(200, { ...responseHeaders('text/html; charset=utf-8'), 'Content-Length': artifactBuffer.byteLength }); + if (req.method === 'HEAD') res.end(); + else res.end(artifactBuffer); + return; + } + if (url.pathname === '/events' && req.method === 'GET') { + res.writeHead(200, { + ...responseHeaders('text/event-stream; charset=utf-8'), + Connection: 'keep-alive', + }); + res.write('retry: 1000\n\n'); + clients.add(res); + sendState(res); + req.on('close', () => clients.delete(res)); + return; + } + + res.writeHead(404, responseHeaders('text/plain; charset=utf-8')); + res.end('Not found'); + }); + + server.on('connection', (socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + // A connection event already queued when force-stop begins must not keep + // server.close() waiting after the current sockets have been destroyed. + if (forceStopping) socket.destroy(); + }); + + try { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, loopbackHost, () => { + server.off('error', reject); + port = server.address().port; + resolve(); + }); + }); + } catch (error) { + try { server.close(); } catch {} + cleanupOwnedDirectory(stagingDirectory, stagingIdentity); + throw error; + } + + const url = `http://${loopbackHost}:${port}/`; + + function finishStop() { + if (stopped || child || !serverClosed) return; + stopped = true; + clearTimeout(debounceTimer); + clearInterval(pollTimer); + clearTimeout(stopGraceTimer); + clearTimeout(stopKillTimer); + try { + cleanupOwnedDirectory(stagingDirectory, stagingIdentity); + } finally { + resolveClosed(); + } + } + + function signalActiveChild(signal) { + if (!child || child.exitCode !== null || child.signalCode !== null) return; + try { + if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal); + else child.kill(signal); + } catch (error) { + if (error.code === 'ESRCH') return; + try { child.kill(signal); } catch {} + } + } + + function closeServer() { + if (serverClosing) return; + serverClosing = true; + for (const res of clients) res.end(); + clients.clear(); + server.close(() => { + serverClosed = true; + finishStop(); + }); + server.closeIdleConnections?.(); + } + + function startBoundedChildDrain() { + if (!child || stopGraceTimer || stopKillTimer) return; + stopGraceTimer = setTimeout(() => { + stopGraceTimer = undefined; + if (!child) return finishStop(); + signalActiveChild('SIGTERM'); + stopKillTimer = setTimeout(() => { + stopKillTimer = undefined; + signalActiveChild('SIGKILL'); + }, stopKillMs); + }, stopGraceMs); + } + + async function stop({ force = false } = {}) { + if (force) forceStopping = true; + if (!stopping) { + stopping = true; + clearTimeout(debounceTimer); + clearInterval(pollTimer); + watcher?.close(); + closeServer(); + } + if (forceStopping) { + for (const socket of sockets) socket.destroy(); + } + if (child && force) { + clearTimeout(stopGraceTimer); + clearTimeout(stopKillTimer); + stopGraceTimer = undefined; + stopKillTimer = undefined; + signalActiveChild('SIGKILL'); + } else if (child) { + startBoundedChildDrain(); + } else { + finishStop(); + } + return closed; + } + + function publishFailure(receipt, stdout, stderr, candidatePath, snapshotPath) { + const repairDetails = receipt?.diagnostics + ?.slice(0, 12) + .map((entry) => { + const fix = entry.supportedFixes?.length ? `\nFix: ${entry.supportedFixes.join('; ')}` : ''; + return `[${entry.code}] ${entry.message}${fix}`; + }) || []; + const checkerDetails = receipt?.checker?.checks + ?.filter((check) => !check.ok) + .flatMap((check) => check.details || []) + .filter(Boolean) + .slice(0, 12) || []; + const diagnostic = [ + receipt?.error, + ...(repairDetails.length ? repairDetails : checkerDetails), + ].filter(Boolean).join('\n') || stderr || stdout; + state.status = 'needs-fix'; + state.failure = { + stage: receipt?.stage || 'render', + ...(receipt?.code ? { code: receipt.code } : {}), + ...(receipt?.evidence ? { evidence: receipt.evidence } : {}), + message: redactDiagnostic( + diagnostic, + [ + [inputPath, ''], + [outputPath, ''], + [snapshotPath, ''], + [candidatePath, ''], + [stagingDirectory, ''], + [path.resolve(here, '..'), ''], + [path.resolve(options.cwd || process.cwd()), ''], + ...(options.repoRoot ? [[path.resolve(options.repoRoot), '']] : []), + ], + ), + }; + broadcast(); + } + + function commitCandidate(candidatePath, receipt, generationHash, outputCapture) { + let candidate; + let sourceCandidateBinding; + let sourceCandidateIdentity; + let commitCandidatePath; + let commitCandidateIdentity; + let commitCandidateBinding; + try { + const sourceCapture = captureRegularFileBinding(candidatePath, { + subject: 'candidate', + expectedSha256: receipt?.artifact?.sha256, + expectedBytes: receipt?.artifact?.bytes, + includeContent: true, + }); + if (sourceCapture.status !== 'captured') throw atomicOutputError(sourceCapture); + sourceCandidateBinding = sourceCapture.binding; + sourceCandidateIdentity = sourceCapture.identity; + candidate = sourceCapture.content.buffer; + const digest = sourceCapture.content.sha256; + const releasedSource = releaseRegularFileBinding(sourceCandidateBinding); + sourceCandidateBinding = undefined; + if (releasedSource.status !== 'released') throw atomicOutputError(releasedSource); + resolveOutputPath(outputRequest); + const sameArtifact = state.lastVerified?.sha256 === digest; + const currentSource = sourceDigest(inputPath); + if (currentSource.hash !== generationHash) { + return { + committed: false, + supersededBy: currentSource, + candidateIdentity: sourceCandidateIdentity, + }; + } + const beforeStage = verifyAtomicOutput(outputCapture.snapshot); + if (beforeStage.status !== 'match') throw atomicOutputError(beforeStage); + ({ + candidatePath: commitCandidatePath, + identity: commitCandidateIdentity, + } = stagePreviewCommit(outputCapture.commitPath, candidate, outputCapture.mode)); + const candidateCapture = captureRegularFileBinding(commitCandidatePath, { + subject: 'candidate', + expectedSha256: digest, + expectedBytes: candidate.byteLength, + expectedIdentity: commitCandidateIdentity, + ...(outputCapture.mode === null ? {} : { expectedMode: outputCapture.mode }), + }); + if (candidateCapture.status !== 'captured') throw atomicOutputError(candidateCapture); + commitCandidateBinding = candidateCapture.binding; + const beforeCommit = verifyAtomicOutput(outputCapture.snapshot); + if (beforeCommit.status !== 'match') throw atomicOutputError(beforeCommit); + const publication = publishRegularFileBinding( + commitCandidateBinding, + commitCandidatePath, + outputCapture.snapshot, + { subject: 'candidate' }, + ); + if (!['committed', 'committed-with-warning'].includes(publication.status)) { + throw atomicOutputError(publication); + } + const releasedCandidate = releaseRegularFileBinding(commitCandidateBinding); + commitCandidateBinding = undefined; + if (releasedCandidate.status !== 'released') throw atomicOutputError(releasedCandidate); + commitCandidatePath = undefined; + commitCandidateIdentity = undefined; + artifactBuffer = candidate; + lastGoodSourceHash = generationHash; + state.status = 'verified'; + if (!sameArtifact) { + state.revision += 1; + state.lastVerified = { + sha256: digest, + bytes: candidate.byteLength, + checksPassed: receipt.validation.checksPassed, + checkCount: receipt.validation.checkCount, + compositionProfile: receipt.validation.compositionProfile, + compositionStatus: receipt.validation.compositionStatus, + }; + } + state.failure = null; + broadcast(); + return { committed: true, supersededBy: null, candidateIdentity: sourceCandidateIdentity }; + } catch (error) { + publishFailure({ + stage: 'commit', + error: `Could not publish the verified preview: ${error.message}`, + ...(error.previewFailure || {}), + }, '', '', candidatePath); + return { committed: false, supersededBy: null, candidateIdentity: sourceCandidateIdentity }; + } finally { + if (sourceCandidateBinding) releaseRegularFileBinding(sourceCandidateBinding); + if (commitCandidateBinding) releaseRegularFileBinding(commitCandidateBinding); + if (commitCandidatePath && commitCandidateIdentity) { + removeOwnedRegularFile(commitCandidatePath, commitCandidateIdentity); + } + } + } + + function beginBuild(digest, epoch) { + if (stopping || child) return; + activeHash = digest.hash; + activeEpoch = epoch; + state.generation += 1; + state.status = 'checking'; + state.failure = null; + broadcast(); + + const candidatePath = path.join(stagingDirectory, `generation-${state.generation}.html`); + const snapshotPath = path.join(stagingDirectory, `generation-${state.generation}.json`); + let snapshotIdentity; + const outputCapture = captureAtomicOutput(outputPath); + if (outputCapture.status !== 'captured') { + const failure = atomicOutputFailure(outputCapture); + publishFailure( + { stage: 'prepare', error: failure.message, ...failure }, + '', + '', + candidatePath, + snapshotPath, + ); + return; + } + const beforeBuild = verifyAtomicOutput(outputCapture.snapshot); + if (beforeBuild.status !== 'match') { + const failure = atomicOutputFailure(beforeBuild); + publishFailure( + { stage: 'prepare', error: failure.message, ...failure }, + '', + '', + candidatePath, + snapshotPath, + ); + return; + } + if (digest.bytes !== null) { + try { + fs.writeFileSync(snapshotPath, digest.bytes, { flag: 'wx', mode: 0o600 }); + const metadata = fs.lstatSync(snapshotPath, { bigint: true }); + if (metadata.isFile() && metadata.ino !== 0n) { + snapshotIdentity = { device: metadata.dev, inode: metadata.ino }; + } + } catch (error) { + publishFailure( + { stage: 'prepare', error: `Could not snapshot the observed input: ${error.message}` }, + '', + '', + candidatePath, + snapshotPath, + ); + return; + } + } + const args = [options.deliveryCli || cliPath, 'deliver', type, snapshotPath, candidatePath, '--json']; + if (options.quality) args.push('--quality', options.quality); + if (options.repoRoot) args.push('--repo-root', path.resolve(options.repoRoot)); + let stdout = ''; + let stderr = ''; + child = spawn(process.execPath, args, { + cwd: options.cwd || process.cwd(), + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + }); + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('error', (error) => { stderr += error.message; }); + child.on('close', (code) => { + const receipt = parseReceipt(stdout); + const generationEpoch = activeEpoch; + const generationHash = activeHash; + const stale = generationEpoch !== sourceEpoch; + let supersededBy = null; + let candidateIdentity; + const deliverySidecars = captureOwnedDeliverySidecars( + candidatePath, + snapshotPath, + receipt, + ); + child = null; + clearTimeout(stopGraceTimer); + clearTimeout(stopKillTimer); + stopGraceTimer = undefined; + stopKillTimer = undefined; + if (!stopping && !stale && code === 0 && receipt?.ok) { + ({ supersededBy, candidateIdentity } = commitCandidate( + candidatePath, + receipt, + generationHash, + outputCapture, + )); + } else if (code === 0 && receipt?.ok) { + const abandonedCandidate = captureRegularFileBinding(candidatePath, { + subject: 'abandoned-preview-candidate', + expectedSha256: receipt.artifact?.sha256, + expectedBytes: receipt.artifact?.bytes, + }); + if (abandonedCandidate.status === 'captured') { + candidateIdentity = abandonedCandidate.identity; + releaseRegularFileBinding(abandonedCandidate.binding); + } + } else if (!stopping && !stale) { + publishFailure(receipt, stdout, stderr, candidatePath, snapshotPath); + } + if (candidateIdentity) removeOwnedRegularFile(candidatePath, candidateIdentity); + if (snapshotIdentity) removeOwnedRegularFile(snapshotPath, snapshotIdentity, { subject: 'snapshot' }); + for (const deliverySidecar of deliverySidecars) { + removeOwnedRegularFile(deliverySidecar.path, deliverySidecar.identity, { + subject: 'preview-delivery-sidecar', + }); + } + + if (stopping) { + finishStop(); + } else if (pendingBuild || stale || supersededBy) { + pendingBuild = false; + if (supersededBy && sourceEpoch === generationEpoch) sourceEpoch += 1; + const digest = supersededBy || sourceDigest(inputPath); + queueStableBuild(digest.hash, true); + } + }); + } + + function queueStableBuild(hash, immediate = false) { + queuedHash = hash; + clearTimeout(debounceTimer); + const launch = () => { + if (stopping) return; + const digest = sourceDigest(inputPath); + if (digest.hash !== queuedHash) { + queueStableBuild(digest.hash); + return; + } + if (digest.hash === lastGoodSourceHash) { + if (state.status !== 'verified' && state.lastVerified) { + state.status = 'verified'; + state.failure = null; + broadcast(); + } + return; + } + if (child) { + pendingBuild = true; + return; + } + beginBuild(digest, sourceEpoch); + }; + debounceTimer = setTimeout(launch, immediate ? 0 : debounceMs); + } + + function observeSource({ immediate = false } = {}) { + const digest = sourceDigest(inputPath); + if (!immediate && digest.hash === queuedHash) return; + sourceEpoch += 1; + queueStableBuild(digest.hash, immediate); + } + + if (options.watch !== false) { + try { + // `path.resolve` keeps Windows 8.3 names intact. Canonicalize short names + // and junctions before libuv opens the directory so its callback path has + // the same prefix as the watched path. + const watchedDirectory = fs.realpathSync.native(path.dirname(inputPath)); + watcher = fs.watch(watchedDirectory, (event, filename) => { + // On Windows the watcher hands us just the basename; on POSIX it can be + // null. Resolve named events against the directory libuv actually opened + // so file-level case, 8.3, and hard-link aliases still identify the input. + if ( + !filename + || sameLocation(path.join(watchedDirectory, filename.toString()), inputPath).status === 'match' + ) { + observeSource(); + } + }); + watcher.on('error', () => { + const failedWatcher = watcher; + watcher = undefined; + failedWatcher?.close(); + }); + } catch (error) { + await stop(); + throw new Error(`Could not watch the input directory: ${error.message}`); + } + } + pollTimer = setInterval(() => observeSource(), pollMs); + + let opener = null; + if (shouldOpen) { + try { + opener = openLoopbackUrl(url); + } catch { + opener = { requested: true, status: 'unsupported', target: url, method: null }; + } + } + + observeSource({ immediate: true }); + + return { + url, + input: inputPath, + output: outputPath, + opener, + state: publicState, + stop, + closed, + }; +} + +export async function runPreview(options) { + const preview = await startPreview(options); + console.log(`preview ${preview.url}`); + console.log(`watching ${preview.input}`); + console.log(`output ${preview.output}`); + if (preview.opener && preview.opener.status !== 'opened') { + console.error(`Could not open the preview (${preview.opener.status}). ${preview.opener.failure?.reason || 'Open it manually.'} Target: ${preview.url}`); + } + + let signalCount = 0; + const stop = () => { + signalCount += 1; + if (signalCount === 1) { + console.log('\nstopping preview…'); + preview.stop(); + } else { + console.log('\nforcing preview shutdown…'); + preview.stop({ force: true }); + } + }; + process.on('SIGINT', stop); + process.on('SIGTERM', stop); + await preview.closed; + process.off('SIGINT', stop); + process.off('SIGTERM', stop); +} diff --git a/ui-plugins/archify-panel/vendor/archify/bin/recover-output.mjs b/ui-plugins/archify-panel/vendor/archify/bin/recover-output.mjs new file mode 100644 index 00000000..9b842613 --- /dev/null +++ b/ui-plugins/archify-panel/vendor/archify/bin/recover-output.mjs @@ -0,0 +1,41 @@ +import { recoverRetiredPublication } from '../renderers/shared/atomic-output.mjs'; + +function usage(stream = process.stderr) { + stream.write('Usage: node bin/recover-output.mjs [--json]\n'); +} + +const args = process.argv.slice(2); +if (args.length === 1 && ['--help', '-h'].includes(args[0])) { + usage(process.stdout); +} else { + let json = false; + let recoveryDirectory; + let invalid = false; + for (const argument of args) { + if (argument === '--json' && !json) { + json = true; + } else if (argument.startsWith('-') || recoveryDirectory !== undefined) { + invalid = true; + } else { + recoveryDirectory = argument; + } + } + + if (invalid || recoveryDirectory === undefined) { + usage(); + process.exitCode = 64; + } else { + const result = recoverRetiredPublication(recoveryDirectory); + if (json) { + process.stdout.write(`${JSON.stringify(result)}\n`); + } else { + const reason = result.reason?.code || 'publication-recovery-unknown'; + process.stdout.write(`${result.status}: ${reason}\n`); + } + // A complete prior recovery is idempotent. A preserved target is a safe, + // deliberate non-action that needs the operator to inspect the claimant. + process.exitCode = ['recovered', 'absent'].includes(result.status) + ? 0 + : result.status === 'preserved' ? 2 : 1; + } +} diff --git a/ui-plugins/archify-panel/vendor/archify/bin/visual-check.mjs b/ui-plugins/archify-panel/vendor/archify/bin/visual-check.mjs new file mode 100644 index 00000000..ceb5054f --- /dev/null +++ b/ui-plugins/archify-panel/vendor/archify/bin/visual-check.mjs @@ -0,0 +1,2780 @@ +import { softwareNoticesHtml } from '../renderers/shared/software-notices.mjs'; +import { spawn } from 'node:child_process'; +import { isExplainerHtml } from '../explainer/check.mjs'; +import { EXPLAINER_VIEWPORTS, inspectExplainerBrowser, explainerObservation, explainerObservationDiagnostics } from '../explainer/browser-check.mjs'; +import { createOwnedTempDirectory } from '../renderers/shared/owned-temp-directory.mjs'; +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'; +import { + backupPublicRegularFileBinding, + captureAtomicOutput, + captureRegularFileBinding, + quarantineRemoveLinkedRegularFileAlias, + quarantineRemoveRegularFileBinding, + releaseRegularFileBinding, + removeEmptyDirectoryWithRetry, + verifyAtomicOutput, + verifyRegularFileBinding, +} from '../renderers/shared/atomic-output.mjs'; +import { + sameEntry, + sameLocation, + sameParent, + sidecarNamespaceComponentKey, +} from '../renderers/shared/path-semantics.mjs'; +import { + boundedSidecarStem, + sidecarStemFromComponent, +} from '../renderers/shared/sidecar-path.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 }), +]); + +export const CAPTURE_VIEWPORTS = Object.freeze([ + VISUAL_CHECK_VIEWPORTS[0], + VISUAL_CHECK_VIEWPORTS[VISUAL_CHECK_VIEWPORTS.length - 1], +]); +export 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'; +const VISUAL_SIDECAR_SUFFIXES = Object.freeze([ + '.visual-check.json', + '.visual-check.html', + ...CAPTURE_VIEWPORTS.flatMap(({ width, height }) => THEMES.map( + (theme) => `.visual-check.${width}x${height}.${theme}.png`, + )), +]); +const visualEvidenceOwnerships = new WeakSet(); +export const CHROME_STARTUP_TIMEOUT_MS = 90000; + +function sha256(buffer) { + return createHash('sha256').update(buffer).digest('hex'); +} + +function htmlEscape(value) { + return String(value).replace(/[&<>"']/g, (char) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[char]); +} + +function pathEntry(file) { + try { + return { exists: true, stat: fs.lstatSync(file) }; + } catch (error) { + if (error?.code === 'ENOENT') return { exists: false }; + return { exists: null, error }; + } +} + +function visualEvidencePaths(outputs) { + return outputs.capture === false ? [] : [outputs.contactSheet, ...outputs.screenshots.map((entry) => entry.path)]; +} + +function regularFileEvidence(file, { expectedLinks = 1 } = {}) { + const captured = captureRegularFileBinding(file, { + subject: 'evidence-file', + expectedLinks, + }); + if (captured.status !== 'captured') { + const systemCode = captured.reason?.systemCode; + return { + ok: false, + reason: systemCode === 'ENOENT' + ? { code: 'missing-evidence-file' } + : captured.reason?.code === 'evidence-file-not-regular-file' + ? { code: 'evidence-path-not-regular-file' } + : { + code: 'evidence-file-unreadable', + ...(systemCode ? { systemCode } : {}), + ...(captured.reason?.code ? { bindingCode: captured.reason.code } : {}), + }, + }; + } + const released = releaseRegularFileBinding(captured.binding); + if (released.status !== 'released') { + return { + ok: false, + reason: { + code: 'evidence-file-unreadable', + ...(released.reason?.systemCode ? { systemCode: released.reason.systemCode } : {}), + ...(released.reason?.code ? { bindingCode: released.reason.code } : {}), + }, + }; + } + return { + ok: true, + evidence: { + file: path.basename(file), + sha256: captured.content.sha256, + bytes: captured.content.bytes, + }, + identity: captured.identity, + }; +} + +function readRegularFileContents(file, { subject = 'file' } = {}) { + const captured = captureRegularFileBinding(file, { + subject, + expectedLinks: 1, + includeContent: true, + }); + if (captured.status !== 'captured') return { ok: false, reason: captured.reason }; + const released = releaseRegularFileBinding(captured.binding); + if (released.status !== 'released') return { ok: false, reason: released.reason }; + return { + ok: true, + bytes: captured.content.buffer, + evidence: { + sha256: captured.content.sha256, + bytes: captured.content.bytes, + }, + identity: captured.identity, + }; +} + +function regularFileCaptureError(label, captured) { + const reason = captured?.reason || { code: 'regular-file-capture-failed' }; + const error = new Error(`${label} could not be read safely (${reason.code}).`); + if (reason.systemCode) error.code = reason.systemCode; + error.evidenceReason = reason; + return error; +} + +function screenshotKey(width, height, theme) { + return `${width}x${height}:${theme}`; +} + +function resolvedSidecarNamespace(artifact, component) { + const namespace = sidecarNamespaceComponentKey(path.dirname(artifact), component); + if (namespace.status === 'resolved') return namespace; + const error = new Error( + `Could not establish a stable sidecar namespace (${namespace.reason?.code || 'unknown'}).`, + ); + error.code = 'ARCHIFY_SIDECAR_NAMESPACE_INDETERMINATE'; + error.sidecarNamespaceReason = namespace.reason; + throw error; +} + +function portableSidecarComponentKey(component) { + return component + .normalize('NFC') + .toLocaleUpperCase('en-US') + .toLocaleLowerCase('en-US') + .normalize('NFC'); +} + +function targetSidecarNamespace(directory, component) { + const namespace = sidecarNamespaceComponentKey(directory, component); + if (namespace.status === 'resolved') return namespace.componentKey; + if (namespace.reason?.code === 'sidecar-directory-missing') { + // A pure path calculation must not create --out-dir. Use the portable + // collision envelope until the directory exists and can be probed. + return portableSidecarComponentKey(component); + } + const error = new Error( + `Could not establish target sidecar semantics (${namespace.reason?.code || 'unknown'}).`, + ); + error.code = 'ARCHIFY_SIDECAR_NAMESPACE_INDETERMINATE'; + error.sidecarNamespaceReason = namespace.reason; + throw error; +} + +function evidenceSidecarPaths(artifactPath, { outDir, command = 'visual-check', capture = true, captureViewports = CAPTURE_VIEWPORTS } = {}) { + let artifact = path.resolve(artifactPath); + try { + // Existing aliases, including Windows 8.3 spellings, must share the + // physical artifact's evidence namespace even when the stem is short. + artifact = fs.realpathSync.native(artifact); + } catch (error) { + if (!['ENOENT', 'ENOTDIR', 'ENAMETOOLONG'].includes(error?.code)) throw error; + // Missing artifacts still need deterministic failure sidecar paths. + } + const namespace = resolvedSidecarNamespace(artifact, path.basename(artifact)); + artifact = path.join(namespace.directoryPath, path.basename(artifact)); + const namespaceStem = sidecarStemFromComponent(namespace.componentKey); + const directory = outDir ? path.resolve(outDir) : path.dirname(artifact); + const targetComponentKey = outDir + ? targetSidecarNamespace(directory, namespace.componentKey) + : namespace.componentKey; + const portableTargetKey = outDir + ? portableSidecarComponentKey(namespace.componentKey) + : namespace.componentKey; + const targetAliasesSourceSpelling = targetComponentKey !== namespace.componentKey + || portableTargetKey !== namespace.componentKey; + const stem = boundedSidecarStem( + namespaceStem.stem, + capture ? VISUAL_SIDECAR_SUFFIXES : ['.browser-check.json'], + targetAliasesSourceSpelling ? { + force: true, + hashDomain: `${command}-artifact\0${artifact}\0target-component\0${portableTargetKey}`, + } : namespaceStem.options, + ); + const base = path.join(directory, `${stem}.${command}`); + const screenshots = (capture ? captureViewports : []).flatMap(({ width, height }) => THEMES.map((theme) => ({ + width, + height, + theme, + path: `${base}.${width}x${height}.${theme}.png`, + }))); + return { + base, command, capture, + receipt: `${base}.json`, + contactSheet: `${base}.html`, + screenshots, + }; +} + +export function sidecarPaths(artifactPath, options = {}) { + return evidenceSidecarPaths(artifactPath, options); +} + +export function browserCheckSidecarPaths(artifactPath, options = {}) { + return evidenceSidecarPaths(artifactPath, { ...options, command: 'browser-check', capture: false }); +} + +function evidencePathConflict(artifactPath, outputs, file, reason) { + const target = file || outputs.receipt; + const error = `${outputs.command} will not replace the existing unowned evidence path "${target}".`; + return { + ok: false, + error, + diagnostic: failureDiagnostic({ + code: 'viewer/evidence-path-conflict', + message: error, + subject: { artifact: path.resolve(artifactPath), evidencePath: target }, + evidence: { reason }, + supportedFixes: [ + `move the conflicting file aside, or choose a separate --out-dir, then rerun ${outputs.command}`, + ], + }), + }; +} + +function visualEvidenceOwnershipMatches(ownership, artifactPath, outputs) { + return visualEvidenceOwnerships.has(ownership) + && ownership.active + && sameEntry(ownership.artifact, artifactPath).status === 'match' + && sameLocation(ownership.receipt, outputs.receipt).status === 'match'; +} + +function visualEvidenceTargets(outputs) { + return [ + { kind: 'receipt', path: outputs.receipt }, + ...(outputs.capture === false ? [] : [{ kind: 'contact-sheet', path: outputs.contactSheet }]), + ...outputs.screenshots.map((entry, index) => ({ + kind: 'screenshot', + index, + path: entry.path, + })), + ]; +} + +function evidenceWriteFailure(artifactPath, message, errors) { + return { + ok: false, + error: message, + diagnostic: failureDiagnostic({ + code: 'viewer/evidence-write', + message, + subject: { artifact: path.resolve(artifactPath) }, + evidence: { errors }, + supportedFixes: ['restore write access to the evidence directory and rerun visual-check'], + }), + }; +} + +function fileIdentity(stat) { + return { + device: stat.dev, + inode: stat.ino, + mode: Number(stat.mode & 0o777n), + links: stat.nlink, + }; +} + +function identityMatches(stat, identity, { directory = false } = {}) { + return stat.dev === identity.device + && stat.ino === identity.inode + && Number(stat.mode & 0o777n) === identity.mode + && (directory ? stat.isDirectory() : stat.isFile()) + && !stat.isSymbolicLink(); +} + +function evidenceIdentityMatches(actual, expected, expectedLinks = expected?.links ?? 1) { + return actual?.device === expected?.device + && actual?.inode === expected?.inode + && actual?.mode === expected?.mode + && BigInt(actual?.links ?? 0) === BigInt(expectedLinks); +} + +function stagedOutputs(outputs, stagingDirectory) { + return { + receipt: path.join(stagingDirectory, 'receipt.json'), + contactSheet: path.join(stagingDirectory, 'contact-sheet.html'), + screenshots: outputs.screenshots.map((entry, index) => ({ + ...entry, + path: path.join(stagingDirectory, `capture-${index}.png`), + })), + }; +} + +function stagedPathFor(ownership, finalPath) { + return ownership.stagingPaths.get(finalPath); +} + +function registerStagedEvidence(ownership, finalPath) { + const stagedPath = stagedPathFor(ownership, finalPath); + let stat; + try { + stat = fs.lstatSync(stagedPath, { bigint: true }); + } catch (error) { + throw new Error(`visual-check could not inspect staged evidence "${stagedPath}": ${error.message}`); + } + if (!stat.isFile() || stat.isSymbolicLink() || stat.ino === 0n) { + throw new Error(`visual-check staged evidence is not a uniquely owned regular file: "${stagedPath}".`); + } + if (stat.nlink !== 1n) { + ownership.stagedEntries.set(finalPath, { + path: stagedPath, + identity: fileIdentity(stat), + }); + throw new Error(`visual-check staged evidence has an external hard link: "${stagedPath}".`); + } + const targetMode = ownership.targets.get(finalPath)?.mode; + if (targetMode !== null && targetMode !== undefined + && Number(stat.mode & 0o777n) !== targetMode) { + fs.chmodSync(stagedPath, targetMode); + const adjusted = fs.lstatSync(stagedPath, { bigint: true }); + if (adjusted.dev !== stat.dev || adjusted.ino !== stat.ino || !adjusted.isFile()) { + throw new Error(`visual-check staged evidence changed while preserving its mode: "${stagedPath}".`); + } + stat = adjusted; + } + const entry = { + path: stagedPath, + identity: fileIdentity(stat), + }; + ownership.stagedEntries.set(finalPath, entry); + const evidence = regularFileEvidence(stagedPath); + if (!evidence.ok || !evidenceIdentityMatches(evidence.identity, entry.identity)) { + throw new Error(`visual-check could not bind staged evidence "${stagedPath}" to its contents.`); + } + entry.evidence = { + sha256: evidence.evidence.sha256, + bytes: evidence.evidence.bytes, + }; +} + +function writeStagedEvidence(ownership, finalPath, contents) { + const stagedPath = stagedPathFor(ownership, finalPath); + const target = ownership.targets.get(finalPath); + const mode = target?.mode ?? 0o666; + let descriptor; + try { + descriptor = fs.openSync(stagedPath, 'wx', mode); + let stat = fs.fstatSync(descriptor, { bigint: true }); + if (!stat.isFile() || stat.ino === 0n) { + throw new Error('the newly created staging entry has no unique regular-file identity'); + } + if (target?.mode !== null && target?.mode !== undefined + && Number(stat.mode & 0o777n) !== target.mode) { + fs.fchmodSync(descriptor, target.mode); + const adjusted = fs.fstatSync(descriptor, { bigint: true }); + if (adjusted.dev !== stat.dev || adjusted.ino !== stat.ino || !adjusted.isFile() + || Number(adjusted.mode & 0o777n) !== target.mode) { + throw new Error('the staged evidence mode could not be preserved'); + } + stat = adjusted; + } + const entry = { + path: stagedPath, + identity: fileIdentity(stat), + }; + ownership.stagedEntries.set(finalPath, entry); + if (stat.nlink !== 1n) { + throw new Error('the newly created staging entry has an external hard link'); + } + fs.writeFileSync(descriptor, contents); + const bytes = Buffer.isBuffer(contents) ? contents : Buffer.from(contents); + entry.evidence = { sha256: sha256(bytes), bytes: bytes.byteLength }; + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +function unlinkOwnedEntry(file, identity, { + evidence, + expectedLinks, +} = {}) { + const verifiedLinks = Number(expectedLinks ?? identity.links ?? 1); + let captured = captureRegularFileBinding(file, { + subject: 'visual-cleanup', + expectedIdentity: identity, + expectedMode: identity.mode, + expectedLinks: verifiedLinks, + ...(evidence ? { + expectedSha256: evidence.sha256, + expectedBytes: evidence.bytes, + } : {}), + }); + const reportedLinks = Number(captured.reason?.links); + if (captured.status === 'unsupported' + && captured.reason?.code === 'visual-cleanup-hardlinked' + && Number.isSafeInteger(reportedLinks) + && reportedLinks > verifiedLinks) { + captured = captureRegularFileBinding(file, { + subject: 'visual-cleanup', + expectedIdentity: identity, + expectedMode: identity.mode, + expectedLinks: reportedLinks, + ...(evidence ? { + expectedSha256: evidence.sha256, + expectedBytes: evidence.bytes, + } : {}), + }); + } + if (captured.status !== 'captured') { + if (captured.reason?.systemCode === 'ENOENT') return { status: 'absent' }; + return { + status: captured.status === 'different' || captured.status === 'unsupported' + ? 'different' + : 'unknown', + reason: captured.reason?.code?.includes('content-changed') ? 'content' : 'identity', + error: new Error(captured.reason?.code || 'visual cleanup binding failed'), + bindingState: captured.reason, + }; + } + let removal; + let released; + try { + removal = quarantineRemoveRegularFileBinding(captured.binding, file, { + subject: 'visual-cleanup', + expectedLinks: captured.identity?.links ? Number(captured.identity.links) : verifiedLinks, + }); + } finally { + released = releaseRegularFileBinding(captured.binding); + } + if (released.status !== 'released') { + return { + status: 'unknown', + reason: 'identity', + error: new Error(released.reason?.code || 'visual cleanup binding release failed'), + bindingState: released.reason, + }; + } + if (removal.status === 'removed' || removal.status === 'absent') return removal; + return { + status: removal.status === 'preserved' || removal.status === 'different' + || removal.status === 'unsupported' ? 'different' : 'unknown', + reason: 'identity', + error: new Error(removal.reason?.code || 'visual cleanup quarantine failed'), + bindingState: removal.reason, + ...(removal.recoveryDirectory ? { + recoveryDirectory: removal.recoveryDirectory, + recoveryFile: removal.recoveryFile, + } : {}), + }; +} + +function backupOwnedPublishedEntry(file, backupPath, identity, evidence) { + const captured = captureRegularFileBinding(file, { + subject: 'previous-evidence', + expectedIdentity: identity, + expectedMode: identity.mode, + expectedSha256: evidence.sha256, + expectedBytes: evidence.bytes, + expectedLinks: 1, + }); + if (captured.status !== 'captured') { + return { ...captured, backupCreated: false, backupVerified: false }; + } + let backup; + let released; + try { + backup = backupPublicRegularFileBinding(captured.binding, file, backupPath, { + subject: 'previous-evidence', + }); + } finally { + released = releaseRegularFileBinding(captured.binding); + } + if (released.status !== 'released') { + return { + status: 'recovery-required', + reason: { + code: 'previous-evidence-binding-release-failed', + bindingState: released.reason, + }, + recoveryDirectory: backup?.recoveryDirectory || path.dirname(backupPath), + recoveryFile: backup?.recoveryFile || (backup?.backupCreated ? backupPath : undefined), + target: backup?.target || file, + backupCreated: backup?.backupCreated === true, + backupVerified: backup?.backupVerified === true, + }; + } + return backup; +} + +function publishedRemovalError(file, result) { + const code = result.reason?.code; + let reason; + if (result.status === 'preserved') { + reason = 'published entry changed during retirement; its successor was restored and preserved'; + } else if (result.status === 'recovery-required') { + reason = 'published entry changed during retirement; recovery material was preserved'; + } else if (code?.includes('content-changed')) { + reason = 'published entry content changed; it was preserved'; + } else if (code?.includes('mode-changed')) { + reason = 'published entry mode changed; it was preserved'; + } else if (result.status === 'different' || result.status === 'unsupported') { + reason = 'published entry identity changed; it was preserved'; + } else { + reason = code || 'published entry could not be safely retired; it was preserved'; + } + return { + file, + reason, + ...(code ? { code } : {}), + ...(result.recoveryDirectory ? { recoveryDirectory: result.recoveryDirectory } : {}), + ...(result.recoveryFile ? { recoveryFile: result.recoveryFile } : {}), + ...(result.target ? { target: result.target } : {}), + }; +} + +function cleanupStagedEvidence(ownership, { removeDirectory = false } = {}) { + const errors = []; + for (const [finalPath, entry] of [...ownership.stagedEntries]) { + const result = unlinkOwnedEntry(entry.path, entry.identity, { evidence: entry.evidence }); + if (result.status === 'removed' || result.status === 'absent') { + ownership.stagedEntries.delete(finalPath); + } else { + errors.push({ + file: entry.path, + reason: result.status === 'different' && result.reason === 'content' + ? 'staging entry content changed; it was preserved' + : result.status === 'different' + ? 'staging entry identity changed; it was preserved' + : result.error?.message, + }); + } + } + const directories = [ + ownership.inspection, + ...(removeDirectory ? [{ + directory: ownership.stagingDirectory, + identity: ownership.stagingIdentity, + }] : []), + ].filter(Boolean); + for (const { directory, identity } of directories) { + try { + const stat = fs.lstatSync(directory, { bigint: true }); + if (!identity || !identityMatches(stat, identity, { directory: true })) { + errors.push({ + file: directory, + recoveryDirectory: directory, + reason: 'staging directory identity changed; it was preserved', + }); + } else { + removeEmptyDirectoryWithRetry(directory); + } + } catch (error) { + if (error?.code !== 'ENOENT') { + errors.push({ file: directory, recoveryDirectory: directory, reason: error.message }); + } + } + } + return errors; +} + +function verifyVisualEvidenceTargets(artifactPath, outputs, ownership) { + for (const target of visualEvidenceTargets(outputs)) { + const captured = ownership.targets.get(target.path); + const verification = captured ? verifyAtomicOutput(captured.snapshot) : { + status: 'unknown', + reason: { code: 'evidence-snapshot-missing' }, + }; + if (verification.status !== 'match') { + return evidencePathConflict(artifactPath, outputs, target.path, verification.reason); + } + } + return { ok: true }; +} + +function beginVisualEvidenceWrite(artifactPath, artifactBytes, outputs) { + const targets = visualEvidenceTargets(outputs); + const candidates = targets.map((target) => target.path); + const previousEvidence = new Map(); + try { + fs.mkdirSync(path.dirname(outputs.receipt), { recursive: true }); + } catch (error) { + return evidenceWriteFailure( + artifactPath, + 'visual-check could not create its evidence directory.', + [{ file: path.dirname(outputs.receipt), reason: error.message }], + ); + } + + const capturedTargets = new Map(); + for (const target of targets) { + const captured = captureAtomicOutput(target.path, { + requestedEntryPolicy: 'regular-or-absent', + }); + if (captured.status !== 'captured') { + return evidencePathConflict(artifactPath, outputs, target.path, captured.reason); + } + capturedTargets.set(target.path, captured); + } + + const parent = capturedTargets.get(outputs.receipt).snapshot.slot; + for (const target of targets.slice(1)) { + const slot = capturedTargets.get(target.path).snapshot.slot; + if (slot.parentDevice !== parent.parentDevice || slot.parentInode !== parent.parentInode) { + return evidencePathConflict(artifactPath, outputs, target.path, { + code: 'evidence-parent-mismatch', + }); + } + } + + const existing = candidates.filter((file) => ( + capturedTargets.get(file).snapshot.requestedEntry.kind === 'existing' + )); + if (existing.length) { + const receiptEntry = capturedTargets.get(outputs.receipt).snapshot.requestedEntry; + if (receiptEntry.kind !== 'existing') { + return evidencePathConflict(artifactPath, outputs, existing[0], { + code: 'ownership-receipt-missing', + }); + } + + const previousReceipt = readRegularFileContents(outputs.receipt, { + subject: 'ownership-receipt', + }); + const capturedReceipt = capturedTargets.get(outputs.receipt).snapshot.target; + if (!previousReceipt.ok || !evidenceIdentityMatches(previousReceipt.identity, { + device: capturedReceipt.device, + inode: capturedReceipt.inode, + mode: capturedReceipt.mode, + links: 1, + })) { + return evidencePathConflict(artifactPath, outputs, outputs.receipt, { + code: 'ownership-receipt-invalid', + ...(previousReceipt.reason?.code ? { bindingCode: previousReceipt.reason.code } : {}), + ...(previousReceipt.reason?.systemCode + ? { systemCode: previousReceipt.reason.systemCode } : {}), + }); + } + const previousBytes = previousReceipt.bytes; + let previous; + try { + previous = JSON.parse(previousBytes.toString('utf8')); + } catch (error) { + return evidencePathConflict(artifactPath, outputs, outputs.receipt, { + code: 'ownership-receipt-invalid', + ...(error?.code ? { systemCode: error.code } : {}), + }); + } + previousEvidence.set(outputs.receipt, { + ...previousReceipt.evidence, + }); + + if (previous?.schemaVersion !== 1 || previous?.command !== outputs.command) { + return evidencePathConflict(artifactPath, outputs, outputs.receipt, { + code: 'ownership-receipt-schema-mismatch', + }); + } + const recordedReceiptName = previous?.sidecars?.receipt; + const recordedContactName = previous?.sidecars?.contactSheet; + const recordedDirectory = previous?.sidecars?.directory || path.dirname(previous?.artifact?.path || ''); + const receiptNameSafe = typeof recordedReceiptName === 'string' + // path-contract-allow: portable-logical-path -- receipt filenames are serialized logical basenames. + && path.basename(recordedReceiptName) === recordedReceiptName; + const contactNameSafe = outputs.capture === false || typeof recordedContactName === 'string' + // path-contract-allow: portable-logical-path -- contact-sheet filenames are serialized logical basenames. + && path.basename(recordedContactName) === recordedContactName; + if (!path.isAbsolute(recordedDirectory) || !receiptNameSafe || !contactNameSafe + || sameLocation(path.join(recordedDirectory, recordedReceiptName), outputs.receipt).status !== 'match' + || (outputs.capture !== false && sameLocation(path.join(recordedDirectory, recordedContactName), outputs.contactSheet).status !== 'match')) { + return evidencePathConflict(artifactPath, outputs, outputs.receipt, { + code: 'ownership-sidecar-path-mismatch', + }); + } + if (typeof previous?.artifact?.path !== 'string' + || !path.isAbsolute(previous.artifact.path) + || sameEntry(previous.artifact.path, artifactPath).status !== 'match' + || previous.artifact.sha256 !== sha256(artifactBytes) + || previous.artifact.bytes !== artifactBytes.byteLength) { + return evidencePathConflict(artifactPath, outputs, outputs.receipt, { + code: 'ownership-artifact-mismatch', + }); + } + + const existingEvidence = visualEvidencePaths(outputs).filter((file) => ( + capturedTargets.get(file).snapshot.requestedEntry.kind === 'existing' + )); + const recordedEvidence = previous?.sidecars?.files; + if (existingEvidence.length && !Array.isArray(recordedEvidence)) { + return evidencePathConflict(artifactPath, outputs, existingEvidence[0], { + code: 'ownership-evidence-digests-missing', + }); + } + if (Array.isArray(recordedEvidence)) { + const allowed = new Map(visualEvidencePaths(outputs).map((file) => [path.basename(file), file])); + const recorded = new Map(); + for (const item of recordedEvidence) { + if (!item || typeof item.file !== 'string' || recorded.has(item.file) || !allowed.has(item.file)) { + return evidencePathConflict(artifactPath, outputs, outputs.receipt, { + code: 'ownership-evidence-list-invalid', + }); + } + recorded.set(item.file, item); + } + if (recorded.size !== existingEvidence.length + || existingEvidence.some((file) => !recorded.has(path.basename(file)))) { + return evidencePathConflict(artifactPath, outputs, outputs.receipt, { + code: 'ownership-evidence-list-mismatch', + }); + } + for (const file of existingEvidence) { + const actual = regularFileEvidence(file); + const expected = recorded.get(path.basename(file)); + if (!actual.ok + || actual.evidence.sha256 !== expected.sha256 + || actual.evidence.bytes !== expected.bytes) { + return evidencePathConflict(artifactPath, outputs, file, { + code: actual.ok ? 'ownership-evidence-digest-mismatch' : actual.reason.code, + ...(!actual.ok && actual.reason.systemCode + ? { systemCode: actual.reason.systemCode } : {}), + }); + } + previousEvidence.set(file, { + sha256: actual.evidence.sha256, + bytes: actual.evidence.bytes, + }); + } + } + + } + + for (const target of targets) { + const verification = verifyAtomicOutput(capturedTargets.get(target.path).snapshot); + if (verification.status !== 'match') { + return evidencePathConflict(artifactPath, outputs, target.path, verification.reason); + } + } + + let stagingDirectory; + let stagingStat; + try { + stagingDirectory = fs.mkdtempSync(path.toNamespacedPath( + path.join(parent.parentPath, '.archify-visual-check-'), + )); + stagingStat = fs.lstatSync(stagingDirectory, { bigint: true }); + if (!stagingStat.isDirectory() || stagingStat.isSymbolicLink() || stagingStat.ino === 0n) { + throw new Error('the newly created staging directory has no stable directory identity'); + } + } catch (error) { + return evidenceWriteFailure( + artifactPath, + 'visual-check could not create a private evidence staging directory.', + [{ file: parent.parentPath, reason: error.message }], + ); + } + + const staged = stagedOutputs(outputs, stagingDirectory); + const ownership = { + active: true, + artifact: path.resolve(artifactPath), + receipt: outputs.receipt, + targets: capturedTargets, + stagingDirectory, + stagingIdentity: fileIdentity(stagingStat), + stagedOutputs: staged, + stagingPaths: new Map([ + [outputs.receipt, staged.receipt], + [outputs.contactSheet, staged.contactSheet], + ...outputs.screenshots.map((entry, index) => [entry.path, staged.screenshots[index].path]), + ]), + stagedEntries: new Map(), + previousEvidence, + }; + visualEvidenceOwnerships.add(ownership); + return { ok: true, ownership }; +} + +function recordVisualEvidenceFiles(receipt, outputs, ownership) { + const files = []; + for (const file of visualEvidencePaths(outputs)) { + const staged = ownership.stagedEntries.get(file); + if (!staged || !currentEvidenceMatches(staged.path, staged.identity, staged.evidence)) { + const error = new Error(`visual-check could not bind evidence file "${file}" to its receipt.`); + error.evidenceReason = { code: 'staged-evidence-content-mismatch' }; + throw error; + } + files.push({ ...staged.evidence, file: path.basename(file) }); + } + receipt.sidecars.files = files; +} + +function currentEvidenceMatches(file, identity, evidence, { + expectedLinks = identity.links ?? 1, +} = {}) { + if (!evidence) return false; + const current = regularFileEvidence(file, { expectedLinks: Number(expectedLinks) }); + return current.ok + && evidenceIdentityMatches(current.identity, identity, expectedLinks) + && current.evidence.sha256 === evidence.sha256 + && current.evidence.bytes === evidence.bytes; +} + +function restoreOwnedBackup(backup, errors) { + const expectedLinks = backup.identity.links ?? 1n; + const expectedLinkCount = Number(expectedLinks); + let backupStat; + try { + backupStat = fs.lstatSync(backup.path, { bigint: true }); + } catch { + errors.push({ file: backup.path, reason: 'backup identity changed; it was preserved' }); + return; + } + if (!identityMatches(backupStat, backup.identity) || backupStat.nlink !== expectedLinks) { + errors.push({ file: backup.path, reason: 'backup identity changed; it was preserved' }); + return; + } + let captured = backup.evidence && Number.isSafeInteger(expectedLinkCount) + ? captureRegularFileBinding(backup.path, { + subject: 'restore-backup', + expectedIdentity: backup.identity, + expectedMode: backup.identity.mode, + expectedSha256: backup.evidence.sha256, + expectedBytes: backup.evidence.bytes, + expectedLinks: expectedLinkCount, + }) + : { status: 'unknown', reason: { code: 'restore-backup-evidence-unavailable' } }; + let contentChanged = false; + if (captured.status !== 'captured' + && captured.reason?.code === 'restore-backup-content-changed') { + captured = captureRegularFileBinding(backup.path, { + subject: 'restore-backup-claimant', + expectedIdentity: backup.identity, + expectedMode: backup.identity.mode, + expectedLinks: expectedLinkCount, + }); + contentChanged = captured.status === 'captured'; + } + if (captured.status !== 'captured') { + errors.push({ + file: backup.path, + reason: 'backup bytes, mode, or identity changed; it was preserved', + ...(captured.reason?.code ? { code: captured.reason.code } : {}), + }); + return; + } + let released; + try { + const finalEntry = pathEntry(backup.finalPath); + if (finalEntry.exists === true) { + errors.push({ + file: backup.path, + reason: 'final path is occupied; the owned backup was preserved', + }); + return; + } + if (finalEntry.exists === null) { + errors.push({ file: backup.finalPath, reason: finalEntry.error?.message }); + return; + } + try { + fs.linkSync(backup.path, backup.finalPath); + const linkedCount = expectedLinkCount + 1; + const staged = verifyRegularFileBinding(captured.binding, { + filePath: backup.path, + expectedLinks: linkedCount, + }); + const published = verifyRegularFileBinding(captured.binding, { + filePath: backup.finalPath, + expectedLinks: linkedCount, + }); + if (staged.status !== 'match' || published.status !== 'match') { + const removed = quarantineRemoveRegularFileBinding( + captured.binding, + backup.finalPath, + { subject: 'restored-evidence', expectedLinks: linkedCount }, + ); + errors.push({ + file: backup.path, + reason: removed.status === 'removed' + ? 'restored entry identity could not be verified; the owned backup was preserved' + : removed.status === 'preserved' + ? 'restored entry changed during cleanup; its successor and the owned backup were preserved' + : 'restored entry changed before rollback; both entries were preserved', + ...(removed.reason?.code ? { code: removed.reason.code } : {}), + ...(removed.recoveryDirectory ? { recoveryDirectory: removed.recoveryDirectory } : {}), + ...(removed.recoveryFile ? { recoveryFile: removed.recoveryFile } : {}), + }); + return; + } + const removed = unlinkOwnedEntry(backup.path, backup.identity, { + evidence: captured.content, + expectedLinks: linkedCount, + }); + const finalMatches = verifyRegularFileBinding(captured.binding, { + filePath: backup.finalPath, + expectedLinks: expectedLinkCount, + }).status === 'match'; + if (removed.status !== 'removed' || !finalMatches) { + errors.push({ file: backup.path, reason: 'restored entry link count could not be finalized' }); + } else if (contentChanged) { + errors.push({ + file: backup.finalPath, + reason: 'the backup content changed externally; its current bytes were restored and preserved', + }); + } + } catch (error) { + errors.push({ + file: backup.path, + reason: error?.code === 'EEXIST' + ? 'final path was claimed during restore; the owned backup was preserved' + : error.message, + }); + } + } finally { + released = releaseRegularFileBinding(captured.binding); + if (released.status !== 'released') { + errors.push({ + file: backup.path, + reason: 'restored backup binding could not be released cleanly', + ...(released.reason?.code ? { code: released.reason.code } : {}), + }); + } + } +} + +function commitVisualEvidence(artifactPath, outputs, ownership, desiredPaths) { + const verification = verifyVisualEvidenceTargets(artifactPath, outputs, ownership); + if (!verification.ok) { + const cleanupErrors = cleanupStagedEvidence(ownership, { removeDirectory: true }); + if (cleanupErrors.length) verification.diagnostic.evidence.rollbackErrors = cleanupErrors; + ownership.active = false; + return verification; + } + + const desired = new Set(desiredPaths); + const stagedBindings = new Map(); + const releaseStagedBindings = () => { + const failures = []; + for (const [finalPath, binding] of stagedBindings) { + const released = releaseRegularFileBinding(binding); + if (released.status !== 'released') { + failures.push({ + file: finalPath, + reason: released.reason?.code, + bindingState: released.reason, + ...(released.reason?.recoveryDirectory + ? { recoveryDirectory: released.reason.recoveryDirectory } : {}), + }); + } + } + stagedBindings.clear(); + return failures; + }; + for (const finalPath of desired) { + const staged = ownership.stagedEntries.get(finalPath); + const captured = staged ? captureRegularFileBinding(staged.path, { + subject: 'staged-evidence', + expectedIdentity: staged.identity, + expectedMode: staged.identity.mode, + expectedSha256: staged.evidence?.sha256, + expectedBytes: staged.evidence?.bytes, + expectedLinks: 1, + }) : null; + if (!staged || captured?.status !== 'captured') { + const failure = evidenceWriteFailure( + artifactPath, + 'visual-check could not verify its staged evidence before publication.', + [{ + file: staged?.path || stagedPathFor(ownership, finalPath), + reason: captured?.reason?.code || 'staged entry missing or changed', + }], + ); + failure.diagnostic.evidence.errors.push( + ...releaseStagedBindings(), + ...cleanupStagedEvidence(ownership, { removeDirectory: true }), + ); + ownership.active = false; + return failure; + } + stagedBindings.set(finalPath, captured.binding); + } + + const backups = []; + const published = []; + let conflict = null; + try { + for (const [index, target] of visualEvidenceTargets(outputs).entries()) { + const captured = ownership.targets.get(target.path); + if (captured.snapshot.target.kind !== 'file') continue; + const backupPath = path.join(ownership.stagingDirectory, `previous-${index}`); + const identity = { + device: captured.snapshot.target.device, + inode: captured.snapshot.target.inode, + mode: captured.snapshot.target.mode, + links: 1n, + }; + const evidence = ownership.previousEvidence.get(target.path); + if (!evidence) { + conflict = evidencePathConflict(artifactPath, outputs, target.path, { + code: 'target-evidence-missing-during-commit', + }); + throw new Error(conflict.error); + } + const backup = { + targetPath: target.path, + finalPath: captured.commitPath, + path: backupPath, + identity, + evidence, + }; + const backupResult = backupOwnedPublishedEntry( + captured.commitPath, + backupPath, + identity, + evidence, + ); + if (backupResult.backupCreated) backups.push(backup); + if (backupResult.status !== 'backed-up' + || !backupResult.backupCreated + || !backupResult.backupVerified) { + conflict = evidencePathConflict(artifactPath, outputs, target.path, { + code: backupResult.reason?.code || 'target-backup-failed-during-commit', + backupCreated: backupResult.backupCreated === true, + backupVerified: backupResult.backupVerified === true, + ...(backupResult.recoveryDirectory + ? { recoveryDirectory: backupResult.recoveryDirectory } : {}), + ...(backupResult.recoveryFile ? { recoveryFile: backupResult.recoveryFile } : {}), + ...(backupResult.target ? { target: backupResult.target } : {}), + ...(backupResult.reason ? { bindingState: backupResult.reason } : {}), + }); + throw new Error(conflict.error); + } + } + + const publicationOrder = visualEvidenceTargets(outputs) + .filter((target) => desired.has(target.path)) + .sort((left, right) => Number(left.kind === 'receipt') - Number(right.kind === 'receipt')); + for (const target of publicationOrder) { + const captured = ownership.targets.get(target.path); + const staged = ownership.stagedEntries.get(target.path); + const stagedBinding = stagedBindings.get(target.path); + const beforeLink = verifyRegularFileBinding(stagedBinding, { + filePath: staged.path, + expectedLinks: 1, + }); + if (beforeLink.status !== 'match') { + conflict = evidencePathConflict(artifactPath, outputs, target.path, { + code: beforeLink.reason?.code?.includes('content-changed') + ? 'staged-evidence-content-mismatch' + : beforeLink.reason?.code || 'staged-evidence-content-mismatch', + }); + throw new Error(conflict.error); + } + try { + fs.linkSync(staged.path, captured.commitPath); + } catch (error) { + if (error?.code === 'EEXIST') { + conflict = evidencePathConflict(artifactPath, outputs, target.path, { + code: 'target-created-during-commit', + }); + } + throw error; + } + published.push({ + finalPath: captured.commitPath, + stagedPath: staged.path, + identity: staged.identity, + evidence: staged.evidence, + binding: stagedBinding, + }); + const stagedLinked = verifyRegularFileBinding(stagedBinding, { + filePath: staged.path, + expectedLinks: 2, + }); + const publishedLinked = verifyRegularFileBinding(stagedBinding, { + filePath: captured.commitPath, + expectedLinks: 2, + }); + if (stagedLinked.status !== 'match' || publishedLinked.status !== 'match') { + conflict = evidencePathConflict(artifactPath, outputs, target.path, { + code: 'published-entry-identity-mismatch', + stagedState: stagedLinked.reason, + publishedState: publishedLinked.reason, + }); + throw new Error(conflict.error); + } + const removed = quarantineRemoveRegularFileBinding(stagedBinding, staged.path, { + subject: 'staged-evidence', + expectedLinks: 2, + }); + if (removed.status !== 'removed' && removed.status !== 'preserved') { + conflict = evidencePathConflict(artifactPath, outputs, target.path, { + code: removed.reason?.code || 'published-entry-identity-mismatch', + }); + throw new Error(`visual-check could not retire staged evidence "${staged.path}".`); + } + if (removed.status === 'removed') ownership.stagedEntries.delete(target.path); + const finalized = verifyRegularFileBinding(stagedBinding, { + filePath: captured.commitPath, + expectedLinks: 1, + }); + if (finalized.status !== 'match') { + conflict = evidencePathConflict(artifactPath, outputs, target.path, { + code: 'published-entry-link-count-mismatch', + bindingState: finalized.reason, + }); + throw new Error(conflict.error); + } + } + + // Receipt publication is the linearization point for the evidence set. + // Recheck every earlier member afterwards so a writer that replaces a PNG + // or the contact sheet while later members publish cannot yield success. + for (const entry of published) { + const finalState = verifyRegularFileBinding(entry.binding, { + filePath: entry.finalPath, + expectedLinks: 1, + }); + if (finalState.status !== 'match') { + conflict = evidencePathConflict(artifactPath, outputs, entry.finalPath, { + code: 'published-set-identity-mismatch', + bindingState: finalState.reason, + }); + throw new Error(conflict.error); + } + } + + for (const backup of backups) { + if (!currentEvidenceMatches( + backup.path, + backup.identity, + ownership.previousEvidence.get(backup.targetPath), + )) { + conflict = evidencePathConflict(artifactPath, outputs, backup.targetPath, { + code: 'backup-content-changed-before-cleanup', + }); + throw new Error(conflict.error); + } + } + + // SMB keeps removed staging names pending until every bound handle closes. + // Publication and backup verification are complete before directory cleanup. + const cleanupErrors = releaseStagedBindings(); + for (const backup of backups) { + const removed = unlinkOwnedEntry(backup.path, backup.identity, { + evidence: backup.evidence, + }); + if (removed.status !== 'removed' && removed.status !== 'absent') { + cleanupErrors.push({ + file: backup.path, + reason: removed.status === 'different' && removed.reason === 'content' + ? 'backup content changed; it was preserved' + : removed.status === 'different' + ? 'backup identity changed; it was preserved' + : removed.error?.message, + }); + } + } + cleanupErrors.push(...cleanupStagedEvidence(ownership, { removeDirectory: true })); + ownership.active = false; + let recoveryDirectory; + if (cleanupErrors.length) { + try { + const retained = fs.lstatSync(ownership.stagingDirectory, { bigint: true }); + if (identityMatches(retained, ownership.stagingIdentity, { directory: true })) { + recoveryDirectory = ownership.stagingDirectory; + } + } catch {} + recoveryDirectory ||= cleanupErrors.find((entry) => entry.recoveryDirectory)?.recoveryDirectory; + } + return { + ok: true, + status: cleanupErrors.length ? 'committed-with-warning' : 'committed', + cleanupErrors, + ...(recoveryDirectory ? { recoveryDirectory } : {}), + committedReceipt: outputs.receipt, + }; + } catch (error) { + const rollbackErrors = []; + for (const entry of [...published].reverse()) { + let removed; + let ownedMismatch; + for (const expectedLinks of [2, 1]) { + const matches = verifyRegularFileBinding(entry.binding, { + filePath: entry.finalPath, + expectedLinks, + }); + if (matches.status === 'match') { + removed = quarantineRemoveRegularFileBinding( + entry.binding, + entry.finalPath, + { subject: 'published-evidence', expectedLinks }, + ); + break; + } + if (matches.reason?.code?.includes('content-changed') + || matches.reason?.code?.includes('mode-changed')) { + ownedMismatch = matches; + } else { + ownedMismatch ||= matches; + } + const reportedLinks = Number(matches.reason?.links); + if (matches.status === 'unsupported' + && matches.reason?.code === 'staged-evidence-hardlinked' + && Number.isSafeInteger(reportedLinks) + && reportedLinks > expectedLinks) { + const current = verifyRegularFileBinding(entry.binding, { + filePath: entry.finalPath, + expectedLinks: reportedLinks, + }); + if (current.status === 'match') { + removed = quarantineRemoveRegularFileBinding( + entry.binding, + entry.finalPath, + { subject: 'published-evidence', expectedLinks: reportedLinks }, + ); + break; + } + } + } + if (!removed) { + const claimantRemoval = quarantineRemoveLinkedRegularFileAlias( + entry.stagedPath, + entry.finalPath, + { subject: 'published-evidence-claimant' }, + ); + removed = claimantRemoval.status === 'removed' ? claimantRemoval : ownedMismatch; + } + if (removed.status !== 'removed' && removed.status !== 'absent') { + rollbackErrors.push(publishedRemovalError(entry.finalPath, removed)); + } + } + for (const backup of [...backups].reverse()) restoreOwnedBackup(backup, rollbackErrors); + rollbackErrors.push(...releaseStagedBindings()); + rollbackErrors.push(...cleanupStagedEvidence(ownership, { removeDirectory: true })); + ownership.active = false; + if (conflict) { + if (rollbackErrors.length) conflict.diagnostic.evidence.rollbackErrors = rollbackErrors; + return conflict; + } + return evidenceWriteFailure( + artifactPath, + 'visual-check could not atomically publish its evidence set.', + [{ reason: error.message }, ...rollbackErrors], + ); + } finally { + releaseStagedBindings(); + } +} + +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, resolveExecutable) { + // Use the target platform's path grammar, not the host's: these candidates + // are checked through an injected platform (see the findChrome tests), and + // the host `path` object on Windows would join POSIX directories with + // backslashes and split PATH on ';'. + const pathApi = platform === 'win32' ? path.win32 : path.posix; + const directories = String(env.PATH || '').split(pathApi.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 = pathApi.join(directory, `${command}${extension}`); + const resolved = resolveExecutable(candidate, platform); + if (resolved) return resolved; + } + } + return null; +} + +export function findChrome({ + env = process.env, + platform = process.platform, + resolveExecutable = executable, +} = {}) { + // Windows environment names are case-insensitive, including when callers + // provide a plain object instead of the special process.env object. + if (platform === 'win32') { + env = Object.fromEntries(Object.entries(env).map(([name, value]) => [name.toUpperCase(), value])); + } + if (Object.prototype.hasOwnProperty.call(env, 'ARCHIFY_CHROME')) { + return resolveExecutable(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') { + const pathApi = path.win32; + for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].filter(Boolean)) { + fixed.push( + pathApi.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'), + pathApi.join(root, 'Chromium', 'Application', 'chrome.exe'), + ); + } + commands.push('chrome', 'google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'); + } else { + commands.push('google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'); + } + + for (const candidate of fixed) { + const resolved = resolveExecutable(candidate, platform); + if (resolved) return resolved; + } + for (const command of commands) { + const resolved = findOnPath(command, env, platform, resolveExecutable); + if (resolved) return resolved; + } + // Preserve every Chrome/Chromium candidate's priority, including portable + // PATH installations, before falling back to Windows' bundled browser. + if (platform === 'win32') { + for (const root of [env['PROGRAMFILES(X86)'], env.PROGRAMFILES, env.LOCALAPPDATA].filter(Boolean)) { + const candidate = path.win32.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'); + const resolved = resolveExecutable(candidate, platform); + if (resolved) return resolved; + } + return findOnPath('msedge', env, platform, resolveExecutable); + } + // macOS and Linux: probe Edge behind every Chrome/Chromium candidate, the + // same way Windows does. #920 asked for `microsoft-edge`, + // `microsoft-edge-stable`, and the macOS application bundle. + if (platform === 'darwin') { + const edgeApp = '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'; + const resolved = resolveExecutable(edgeApp, platform); + if (resolved) return resolved; + } + for (const command of ['microsoft-edge', 'microsoft-edge-stable']) { + const resolved = findOnPath(command, env, platform, resolveExecutable); + if (resolved) return resolved; + } + return null; +} + +class PipeCdp { + constructor(child, { failureDetails = () => '' } = {}) { + this.child = child; + this.failureDetails = failureDetails; + this.nextId = 1; + this.buffer = ''; + this.receivedBytes = 0; + this.receivedMessages = 0; + this.completedWrites = 0; + this.writtenBytes = 0; + this.pending = new Map(); + this.waiters = []; + this.subscribers = new Set(); + 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))); + this.readPipe.once('end', () => this.failAll(this.failure('read pipe', new Error('stream ended')))); + this.readPipe.once('close', () => this.failAll(this.failure('read pipe', new Error('stream closed')))); + this.writePipe.once('close', () => this.failAll(this.failure('write pipe', new Error('stream closed')))); + 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(); + const failure = new Error([ + `Chrome DevTools ${stage} failed: ${error?.message || String(error)}${code}`, + details, + `Chrome transport: Node ${process.version}, libuv ${process.versions.uv}, ${process.platform}; pid=${this.child.pid ?? 'unavailable'}, exitCode=${this.child.exitCode}, signalCode=${this.child.signalCode}.`, + `Chrome read pipe: readable=${this.readPipe.readable}, ended=${this.readPipe.readableEnded}, destroyed=${this.readPipe.destroyed}, receivedBytes=${this.receivedBytes}, messages=${this.receivedMessages}, bufferedBytes=${Buffer.byteLength(this.buffer)}.`, + `Chrome write pipe: writable=${this.writePipe.writable}, ended=${this.writePipe.writableEnded}, destroyed=${this.writePipe.destroyed}, completedWrites=${this.completedWrites}, writtenBytes=${this.writtenBytes}, bufferedBytes=${this.writePipe.writableLength}.`, + ].filter(Boolean).join('\n')); + if (error?.code) failure.code = error.code; + if (error?.method) failure.method = error.method; + return failure; + } + + consume(chunk) { + this.receivedBytes += Buffer.byteLength(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; + } + this.receivedMessages += 1; + 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 subscriber of this.subscribers) subscriber(message); + 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 || {}); + } + } + } + + subscribe(callback) { + this.subscribers.add(callback); + return () => this.subscribers.delete(callback); + } + + 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); + const error = new Error(`${method}: timed out after ${timeoutMs}ms`); + error.code = 'ERR_CHROME_CDP_TIMEOUT'; + error.method = method; + reject(this.failure('protocol timeout', error)); + }, timeoutMs); + this.pending.set(id, { method, resolve, reject, timer }); + try { + const serialized = `${JSON.stringify(message)}\0`; + this.writePipe.write(serialized, (error) => { + if (error) { + this.failAll(this.failure('write pipe', error)); + } else { + this.completedWrites += 1; + this.writtenBytes += Buffer.byteLength(serialized); + } + }); + } 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(this.failure('event timeout', 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; +} + +function browserEnvironment(env, platform) { + if (platform !== 'win32') return env; + // Agent-launched Git Bash can inherit this app-health shim. Edge then + // relaunches and loses the DevTools pipes (#790). Change the browser's copy + // only, and leave unknown/combined layers (including privilege settings) alone. + return Object.fromEntries(Object.entries(env).filter(([name, value]) => + !(name.toUpperCase() === '__COMPAT_LAYER' + && /^\s*DetectorsAppHealth\s*$/i.test(String(value))))); +} + +export class ChromeVisualBrowser { + #profile; + constructor(chromePath, { + platform = process.platform, + env = process.env, + getuid = typeof process.getuid === 'function' ? () => process.getuid() : null, + spawnImpl = spawn, + startupTimeoutMs = CHROME_STARTUP_TIMEOUT_MS, + shutdownTimeoutMs = 1500, + } = {}) { + this.#profile = createOwnedTempDirectory('archify-visual-check-profile-'); + this.profileRoot = this.#profile.path; + this.shutdownTimeoutMs = shutdownTimeoutMs; + this.stderr = ''; + const args = chromeVisualBrowserArgs(this.profileRoot, { env, getuid }); + this.child = spawnImpl(chromePath, args, { + stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'], + env: browserEnvironment(env, platform), + }); + 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}` : '', + platform === 'win32' && this.cdp?.receivedBytes === 0 + ? 'Windows browser startup may have relaunched before connecting to the DevTools pipe. ' + + 'Retry from a non-elevated PowerShell opened normally, outside an elevated or compatibility-mode parent. ' + + 'Archify does not disable browser de-elevation or the sandbox to preserve pipe handles.' + : '', + ].filter(Boolean).join('\n'); + }, + }); + this.startupTimeoutMs = startupTimeoutMs; + this.sessionPromise = this.attach(); + } + + async attach() { + // Process launch can be delayed substantially on a busy desktop. Keep the + // longer allowance inside one gate invocation so an authoring agent does + // not turn startup jitter into repeated tool calls or candidate edits. + const targets = await this.cdp.send( + 'Target.getTargets', {}, undefined, this.startupTimeoutMs, + ); + 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 }; + } + this.targetId = target.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 resetPage() { + await this.sessionPromise; + const previousTarget = this.targetId; + const created = await this.cdp.send('Target.createTarget', { url: 'about:blank' }); + const attached = await this.cdp.send('Target.attachToTarget', { targetId: created.targetId, flatten: true }); + await this.cdp.send('Page.enable', {}, attached.sessionId); + await this.cdp.send('Runtime.enable', {}, attached.sessionId); + this.targetId = created.targetId; + this.sessionPromise = Promise.resolve(attached.sessionId); + if (previousTarget) await this.cdp.send('Target.closeTarget', { targetId: previousTarget }); + await this.cdp.send('Target.activateTarget', { targetId: this.targetId }); + await this.cdp.send('Page.bringToFront', {}, attached.sessionId); + } + + async inspect({ artifactPath, width, height, theme, screenshotPath, writeScreenshot }) { + const sessionId = await this.sessionPromise; + await this.cdp.send('Emulation.setDeviceMetricsOverride', { + width, + height, + deviceScaleFactor: 1, + mobile: false, + }, sessionId); + + const url = pathToFileURL(artifactPath); + url.searchParams.set('theme', theme); + const loaded = this.cdp.waitFor('Page.loadEventFired', sessionId); + // Navigation can fail before this waiter is awaited. Attach a rejection + // handler immediately so a later load failure never escapes as an + // unhandled rejection; awaiting `loaded` below still reports it normally. + loaded.catch(() => {}); + 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(); + if (window.Archify && Archify.layoutStability && typeof Archify.layoutStability.whenStable === 'function') { + return fontsReady.then(function () { return Archify.layoutStability.whenStable(); }); + } + 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; + var minimumNonEdge = null; + var minimumEdge = null; + if (svg && scale > 0) { + Array.from(svg.querySelectorAll('text[data-node-label], text[data-boundary-label], text[data-detail="context"], g[data-detail="context"] text')).forEach(function (text) { + if (text.getAttribute('data-detail') === 'fine' || text.closest('[data-detail="fine"]')) return; + var edge = text.closest('[data-edge-from][data-edge-to]'); + var node = text.closest('[data-node-id]'); + var context = text.getAttribute('data-detail') === 'context' || Boolean(text.closest('g[data-detail="context"]')); + var detail = text.hasAttribute('data-node-label') + ? 'primary' + : text.hasAttribute('data-boundary-label') ? 'boundary' + : context && edge ? 'edge' : 'context'; + if (detail === 'context' && !node) return; + var sourceFontPx = parseFloat(text.getAttribute('font-size') || ''); + if (!Number.isFinite(sourceFontPx)) return; + var projectedFontPx = sourceFontPx * scale; + var entry = { + text: (text.textContent || '').trim(), + detail: detail, + owner: edge ? { + kind: 'edge', + id: edge.getAttribute('data-edge-id'), + from: edge.getAttribute('data-edge-from'), + to: edge.getAttribute('data-edge-to') + } : node ? { kind: 'node', id: node.getAttribute('data-node-id') } + : detail === 'boundary' ? { kind: 'boundary', id: null } : null, + sourceFontPx: sourceFontPx, + projectedFontPx: projectedFontPx + }; + if (!minimum || projectedFontPx < minimum.projectedFontPx) minimum = entry; + if (detail === 'edge') { + if (!minimumEdge || projectedFontPx < minimumEdge.projectedFontPx) minimumEdge = entry; + } else if (!minimumNonEdge || projectedFontPx < minimumNonEdge.projectedFontPx) { + minimumNonEdge = entry; + } + }); + } + 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); + // The dock may lift to the viewport floor while the page scrolls; the + // stage contract is about its resting position. + var navigationDockRect = !navigationDock ? null + : window.Archify && Archify.viewerChromeLayout && typeof Archify.viewerChromeLayout.dockRect === 'function' + ? Archify.viewerChromeLayout.dockRect() + : navigationDock.getBoundingClientRect(); + var stageDockIntersectionArea = intersectionArea(stageRect, navigationDockRect); + var viewerChromeReceipt = window.Archify && Archify.viewerChromeLayout + && typeof Archify.viewerChromeLayout.receipt === 'function' + ? Archify.viewerChromeLayout.receipt() + : null; + // Read rendered boxes only; source ownership, offsets and hard pins are unknown. + var workflowLanes = svg ? Array.from(svg.querySelectorAll('rect[data-composition-frame-kind="lane"]')).map(function (lane) { + var rect = lane.getBoundingClientRect(); + var members = Array.from(svg.querySelectorAll('g[data-node-id]')).map(function (node) { + var box = node.querySelector('rect'); + if (!box) return null; + var bounds = box.getBoundingClientRect(); + if (bounds.top < rect.top - 1 || bounds.bottom > rect.bottom + 1 + || bounds.left < rect.left - 1 || bounds.right > rect.right + 1) return null; + return { id: node.getAttribute('data-node-id'), top: bounds.top, bottom: bounds.bottom }; + }).filter(Boolean); + var top = members.length ? members.reduce(function (minimum, node) { return Math.min(minimum, node.top); }, Infinity) : null; + var bottom = members.length ? members.reduce(function (maximum, node) { return Math.max(maximum, node.bottom); }, -Infinity) : null; + return { + frameId: lane.getAttribute('data-composition-frame-id'), + heightPx: Math.round(rect.height), + nodeCount: members.length, + nodeIds: members.slice(0, 12).map(function (node) { return node.id; }), + nodeSpanPx: top === null ? null : Math.round(bottom - top), + spaceAboveNodesPx: top === null ? null : Math.round(top - rect.top), + spaceBelowNodesPx: bottom === null ? null : Math.round(rect.bottom - bottom) + }; + }).sort(function (a, b) { return b.heightPx - a.heightPx; }).slice(0, 6) : []; + // Vertical page budget: every block that stacks above or below the SVG, + // so an overflow receipt can say which part must give up how many pixels. + function outerHeight(element) { + if (!element) return 0; + var style = window.getComputedStyle(element); + if (element.hidden || style.display === 'none') return 0; + return element.getBoundingClientRect().height + + (parseFloat(style.marginTop) || 0) + (parseFloat(style.marginBottom) || 0); + } + var bodyStyle = window.getComputedStyle(document.body); + var diagramStyle = diagram ? window.getComputedStyle(diagram) : null; + var svgHeight = svg ? svg.getBoundingClientRect().height : 0; + var notesBelowFold = Boolean(reader) && document.documentElement.getAttribute('data-reader-rail') === 'bottom'; + var pageComposition = { + bodyPaddingPx: Math.round((parseFloat(bodyStyle.paddingTop) || 0) + (parseFloat(bodyStyle.paddingBottom) || 0)), + headerPx: Math.round(outerHeight(reader && reader.querySelector('.header'))), + diagramChromePx: Math.round(outerHeight(diagram) - svgHeight), + svgPx: Math.round(svgHeight), + cardsPx: notesBelowFold ? 0 : Math.round(outerHeight(reader && reader.querySelector('.cards'))), + viewBoxHeight: viewBox ? viewBox.height : 0 + }; + var svgRect = svg && svg.getBoundingClientRect(); + var diagramRect = diagram && diagram.getBoundingClientRect(); + var documentScrollUnclipped = Boolean(svgRect && diagramRect + && svgRect.top >= diagramRect.top - 1 && svgRect.left >= diagramRect.left - 1 + && svgRect.bottom <= diagramRect.bottom + 1 && svgRect.right <= diagramRect.right + 1 + && diagram.scrollHeight <= diagram.clientHeight + 1 + && diagram.scrollWidth <= diagram.clientWidth + 1 + && [document.documentElement, document.body].every(function (element) { + return !['hidden', 'clip'].includes(window.getComputedStyle(element).overflowY); + })); + return { + pageComposition: pageComposition, + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + scrollWidth: Math.ceil(document.documentElement.scrollWidth), + // Bottom notes and index are reading material below the fold; the + // Reader excludes them from the one-screen fit, and so does this check. + scrollHeight: Math.max(window.innerHeight, Math.ceil(document.documentElement.scrollHeight - ( + notesBelowFold ? outerHeight(reader.querySelector('.reader-rail')) : 0))), + resolvedTheme: document.documentElement.getAttribute('data-theme') || '', + readerLayout: document.documentElement.getAttribute('data-reader-layout') || null, + readerOverflow: document.documentElement.getAttribute('data-reader-overflow') || null, + readerFit: svg ? svg.getAttribute('data-reader-fit') : null, + diagramType: svg ? svg.getAttribute('data-diagram-type') : null, + documentScrollUnclipped: documentScrollUnclipped, + readerWidth: reader ? reader.getBoundingClientRect().width : 0, + diagramWidth: diagramWidth, + viewBoxWidth: viewBoxWidth, + workflowLanes: workflowLanes, + minimumProjectedNodeTextPx: minimum ? minimum.projectedFontPx : null, + minimumProjectedNonEdgeTextPx: minimumNonEdge ? minimumNonEdge.projectedFontPx : null, + minimumProjectedEdgeTextPx: minimumEdge ? minimumEdge.projectedFontPx : null, + minimumProjectedNodeText: minimum ? minimum.text : null, + minimumProjectedNodeTextDetail: minimum ? minimum.detail : null, + minimumProjectedNodeTextOwner: minimum ? minimum.owner : 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.'); + const screenshot = Buffer.from(capture.data, 'base64'); + if (writeScreenshot) writeScreenshot(screenshot); + else fs.writeFileSync(screenshotPath, screenshot, { flag: 'wx' }); + } + return metrics; + } + + close() { + if (!this.closePromise) this.closePromise = this.finishClose(); + return this.closePromise; + } + + async finishClose() { + this.cdp.failAll(new Error('visual-check finished')); + try { + if (this.child.exitCode === null && this.child.signalCode === null) { + const exited = () => this.child.exitCode !== null || this.child.signalCode !== null; + const signalAndWait = (signal) => new Promise((resolve, reject) => { + let timer; + const finish = () => { + clearTimeout(timer); + this.child.removeListener('exit', finish); + resolve(true); + }; + this.child.once('exit', finish); + timer = setTimeout(() => { + this.child.removeListener('exit', finish); + resolve(exited()); + }, this.shutdownTimeoutMs); + try { + this.child.kill(signal); + } catch (error) { + clearTimeout(timer); + this.child.removeListener('exit', finish); + reject(error); + } + }); + if (!await signalAndWait('SIGTERM') && !await signalAndWait('SIGKILL')) { + const error = new Error(`Chrome did not exit; retained temporary profile "${this.#profile.path}".`); + error.code = 'ARCHIFY_TEMP_CLEANUP'; + error.directory = this.#profile.path; + throw error; + } + } + } finally { + // `exit` only reports that Chrome's main process is gone. A helper may + // still hold an inherited pipe open, so explicitly release every stream + // this browser instance owns before its test cleanup hook resolves. + for (const stream of [this.child.stderr, this.child.stdio[3], this.child.stdio[4]]) { + if (stream && !stream.destroyed) stream.destroy(); + } + } + await this.#profile.cleanup(); + } +} + +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 minimumProjectedNonEdgeTextPx = metrics.minimumProjectedNonEdgeTextPx == null + ? null + : Number(metrics.minimumProjectedNonEdgeTextPx); + const minimumProjectedEdgeTextPx = metrics.minimumProjectedEdgeTextPx == null + ? null + : Number(metrics.minimumProjectedEdgeTextPx); + const readabilityOk = minimumProjectedNodeTextPx == null + || minimumProjectedNodeTextPx >= MIN_PROJECTED_NODE_TEXT_PX; + const readerLayout = metrics.readerLayout || null; + const readerOverflow = metrics.readerOverflow || null; + const readerFit = metrics.readerFit || null; + const verticalScrollAccepted = Boolean( + overflowY + && !overflowX + && readabilityOk + && Number.isFinite(minimumProjectedNodeTextPx) + && ((readerLayout === 'adaptive' && readerOverflow === 'authored' && (readerFit === 'intrinsic-height' || readerFit === 'width-first')) + || (readerFit === 'authored-height' && metrics.diagramType === 'architecture' + && metrics.documentScrollUnclipped === true)) + ); + const authoredClipped = readerFit === 'authored-height' && metrics.diagramType === 'architecture' + && metrics.documentScrollUnclipped !== true; + const containmentOk = !authoredClipped && !overflowX && (!overflowY || verticalScrollAccepted); + 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, + verticalScrollAccepted, + overflowDisposition: authoredClipped || overflowX || (overflowY && !verticalScrollAccepted) + ? 'unexpected-overflow' + : verticalScrollAccepted ? 'readable-vertical-scroll' : 'contained', + readerLayout, + readerOverflow, + readerFit, + ...(readerFit === 'authored-height' ? { diagramType: metrics.diagramType, documentScrollUnclipped: metrics.documentScrollUnclipped === true } : {}), + ok: containmentOk, + readerWidth: Number(metrics.readerWidth) || null, + diagramWidth: Number(metrics.diagramWidth) || null, + ...(metrics.pageComposition ? { pageComposition: metrics.pageComposition } : {}), + viewBoxWidth: Number(metrics.viewBoxWidth) || null, + ...(metrics.workflowLanes?.length ? { workflowLanes: metrics.workflowLanes } : {}), + minimumProjectedNodeTextPx, + minimumProjectedNonEdgeTextPx, + minimumProjectedEdgeTextPx, + minimumProjectedNodeText: metrics.minimumProjectedNodeText || null, + minimumProjectedNodeTextDetail: metrics.minimumProjectedNodeTextDetail || null, + minimumProjectedNodeTextOwner: metrics.minimumProjectedNodeTextOwner || 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) => ` +
+ ${htmlEscape(`${entry.theme} ${entry.width} by ${entry.height}`)} +
${htmlEscape(entry.theme.toUpperCase())} · ${entry.width}×${entry.height} · containment ${entry.ok ? 'pass' : 'fail'}${entry.verticalScrollAccepted ? ' · readable vertical scroll' : ''}
+
`).join(''); + return ` + + +${softwareNoticesHtml} + +Archify automated browser evidence · ${htmlEscape(path.basename(artifactPath))} + + + +

Automated browser evidence

${htmlEscape(path.basename(artifactPath))} · visual-check containment ${htmlEscape(receipt.containment.status)} · perceptual visual review pending

+
${cards} +
+ + +`; +} + +const publishedBrowserEvidenceReceipts = new WeakSet(); + +// Keep detailed observations in the bound receipt; expose every review image and +// every failure without repeating the same viewport metrics in CLI context. +export function summarizeBrowserEvidence(receipt) { + const directory = receipt.sidecars?.directory || path.dirname(receipt.artifact.path); + const evidencePath = (file) => file ? path.resolve(directory, file) : undefined; + const published = publishedBrowserEvidenceReceipts.has(receipt); + return { + schemaVersion: receipt.schemaVersion, + command: receipt.command, + ok: receipt.ok, + status: receipt.status, + evidenceKind: receipt.evidenceKind, + visualReview: receipt.visualReview, + artifact: receipt.artifact, + provenance: receipt.provenance, + deliveryReceiptId: receipt.deliveryReceiptId, + state: receipt.state, + chrome: receipt.chrome, + checks: Object.fromEntries(['containment', 'readability', 'viewerChrome', 'themeStates', 'captures'] + .filter((key) => receipt[key]) + .map((key) => [key, receipt[key].status])), + error: receipt.error, + publication: receipt.publication, + diagnostics: receipt.diagnostics || [], + evidence: { + receipt: published ? evidencePath(receipt.sidecars?.receipt) : undefined, + contactSheet: published ? evidencePath(receipt.captures?.contactSheet) : undefined, + screenshots: (published ? receipt.captures?.screenshots || [] : []).map((entry) => ({ + path: evidencePath(entry.file), + width: entry.width, + height: entry.height, + theme: entry.theme, + resolvedTheme: entry.resolvedTheme, + })), + }, + }; +} + +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 resolveSidecarDirectory(artifactPath, outputs, compareParents) { + const artifact = path.resolve(artifactPath); + const artifactDirectory = path.dirname(artifact); + const sidecarDirectory = path.dirname(outputs.receipt); + const comparison = compareParents(outputs.receipt, artifact); + + const sidecars = { + ...(comparison?.status === 'different' ? { directory: sidecarDirectory } : {}), + receipt: path.basename(outputs.receipt), + ...(outputs.capture === false ? {} : { contactSheet: path.basename(outputs.contactSheet) }), + }; + if (comparison?.status === 'match' || comparison?.status === 'different') { + return { ok: true, sidecars }; + } + + const reason = comparison?.reason || { code: 'invalid-path-identity-result' }; + const error = `visual-check could not determine the physical identity of its evidence directory "${sidecarDirectory}" relative to the artifact directory "${artifactDirectory}".`; + return { + ok: false, + sidecars, + error, + diagnostic: failureDiagnostic({ + code: 'viewer/sidecar-directory-identity', + message: error, + subject: { artifact, sidecarDirectory }, + evidence: { comparison: reason }, + supportedFixes: [ + 'use existing, accessible artifact and evidence directories, then rerun visual-check', + ], + }), + }; +} + +// Turn a measured vertical overflow into the pixel budget an author can act on: +// which stacked block holds the height, and what the SVG or cards must shrink to. +export function verticalBudgetFixes(entry) { + const page = entry.pageComposition; + if (!entry.overflowY || !page) return []; + const excess = entry.scrollHeight - entry.innerHeight; + const fixes = []; + const stacked = `${page.bodyPaddingPx}px body padding + ${page.headerPx}px header + ${page.diagramChromePx}px diagram chrome + ${page.svgPx}px SVG + ${page.cardsPx}px cards = ${entry.scrollHeight}px against ${entry.innerHeight}px`; + if (page.svgPx > 0 && page.viewBoxHeight > 0) { + const targetSvg = page.svgPx - excess; + const targetViewBoxHeight = Math.floor(page.viewBoxHeight * targetSvg / page.svgPx); + if (entry.readerLayout === 'adaptive') { + fixes.push(targetSvg > 0 + ? `the page is ${excess}px too tall (${stacked}); the Reader already narrowed the stage to its ${entry.diagramWidth}px minimum, so the SVG height only follows meta.viewBox: keep every node and relationship and reduce the viewBox height to at most ${targetViewBoxHeight} (from ${page.viewBoxHeight}) by tightening vertical gaps and empty rows` + : `the page is ${excess}px too tall (${stacked}) and the SVG alone exceeds the viewport at the minimum reader width; split the diagram into two`); + } else { + fixes.push(`the page is ${excess}px too tall (${stacked}); the SVG spans the full ${entry.diagramWidth}px reader width because its viewBox ratio is below 1.55 and it declares no automatic Reader fit, so the Reader can neither narrow it nor accept readable vertical scroll: either remove meta.viewBox so the renderer sizes the canvas and declares the fit, or make the viewBox at least 1.55x wider than tall`); + } + } + if (page.cardsPx >= excess) { + fixes.push(`alternatively, conclusion cards occupy ${page.cardsPx}px below the diagram; shortening card copy or dropping a card row so cards take at most ${page.cardsPx - excess}px also fits`); + } + return fixes; +} + +function observationDiagnostics({ artifact, allObservations, readabilityObservations, command }) { + const diagnostics = []; + for (const entry of allObservations) { + if (entry.resolvedTheme !== entry.theme) { + diagnostics.push(failureDiagnostic({ + code: 'viewer/theme-state', + message: `The rendered artifact resolved ${entry.resolvedTheme || 'no theme'} instead of the requested ${entry.theme} theme at ${entry.width}x${entry.height}.`, + subject: viewportSubject(artifact, entry), + evidence: { + requestedTheme: entry.theme, + resolvedTheme: entry.resolvedTheme, + }, + supportedFixes: [ + `restore deterministic ${entry.theme} theme resolution, then rerun ${command}`, + ], + })); + } + if (!entry.ok) { + const authoredClipped = entry.readerFit === 'authored-height' && entry.diagramType === 'architecture' + && !entry.documentScrollUnclipped; + const budgetFixes = authoredClipped + ? ['restore the full SVG inside the diagram panel and allow normal document scrolling; remove internal scrollers and clipping without changing authored geometry'] + : verticalBudgetFixes(entry); + diagnostics.push(failureDiagnostic({ + code: authoredClipped ? 'viewer/diagram-clipped' : 'viewer/viewport-overflow', + message: authoredClipped + ? `The authored Architecture canvas is clipped or cannot scroll in the document at ${entry.width}x${entry.height} (${entry.theme}).` + : `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, + overflowDisposition: entry.overflowDisposition, + readerLayout: entry.readerLayout, + readerOverflow: entry.readerOverflow, + readerFit: entry.readerFit, + ...(authoredClipped ? { documentScrollUnclipped: false } : {}), + ...(entry.overflowY && entry.pageComposition ? { + pageComposition: entry.pageComposition, + pageCompositionMeasurement: 'CSS pixels at this viewport; body padding, header, diagram chrome, SVG and cards stack vertically and sum to scrollHeight', + } : {}), + ...(entry.overflowY && entry.workflowLanes?.length ? { + workflowLanes: entry.workflowLanes, + measurement: 'CSS pixels; rendered node boxes geometrically contained in each lane frame; spaces include headers and routing, not guaranteed removable space', + } : {}), + }, + supportedFixes: [ + ...budgetFixes, + ...(entry.overflowY && entry.workflowLanes?.length ? [ + 'run validate workflow --layout-json and compare the tallest rendered lane frames with source lanes, col and yOffset; frame IDs are rendered indices, not source lane IDs', + 'where ownership and explicit geometry permit, distribute stacked steps across logical columns and meaningful lanes before increasing yOffset; preserve nodes, branches, labels and hard pins', + `read references/authoring-contract.md#workflow-viewport-repair, then validate and deliver the changed source before rerunning ${command} on the new artifact; this is inspection guidance, not a verified coordinate fix`, + ] : budgetFixes.length ? [] : [`contain the rendered layout within ${entry.width}x${entry.height}, then rerun ${command}`]), + ], + })); + } + 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 ${command}`, + ], + })); + } + 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 ${command}`, + ], + })); + } + } + 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, + owner: entry.minimumProjectedNodeTextOwner, + minimumProjectedNodeTextPx: entry.minimumProjectedNodeTextPx, + minimumRequiredNodeTextPx: entry.minimumRequiredNodeTextPx, + }, + supportedFixes: [ + `increase projected ${entry.minimumProjectedNodeTextOwner?.kind === 'edge' ? 'relationship label' : entry.minimumProjectedNodeTextOwner?.kind === 'boundary' ? 'boundary label' : 'node text'} to at least ${entry.minimumRequiredNodeTextPx}px at ${entry.width}x${entry.height}, then rerun ${command}`, + ], + })); + } + return diagnostics; +} + +function baseReceipt({ artifactPath, artifact, sidecars, chrome, deliveryProvenance, command, capture }) { + return { + schemaVersion: 1, + ok: false, + command, + evidenceKind: 'automated-browser', + status: 'fail', + visualReview: capture ? 'pending' : 'not-requested', + ...(deliveryProvenance ? { + provenance: deliveryProvenance.status, + ...(deliveryProvenance.receiptId ? { deliveryReceiptId: deliveryProvenance.receiptId } : {}), + } : {}), + artifact: { + path: artifactPath, + sha256: sha256(artifact), + bytes: artifact.byteLength, + }, + state: { detail: 'read', motion: 'still' }, + chrome, + diagnostics: [], + containment: { + status: 'fail', + policy: 'fit-or-reader-declared-readable-vertical-scroll', + viewports: [], + }, + themeStates: { status: 'fail', viewports: [] }, + readability: { status: 'fail', minimumProjectedNodeTextPx: MIN_PROJECTED_NODE_TEXT_PX, viewports: [] }, + viewerChrome: { status: 'fail', viewports: [] }, + captures: { status: capture ? 'fail' : 'not-requested', screenshots: [], contactSheet: null }, + sidecars: { ...sidecars, files: [] }, + }; +} + +function appendEvidencePublicationFailure(receipt, failure) { + if (!failure?.ok) { + if (!receipt.error) receipt.error = failure.error; + receipt.diagnostics = [...(receipt.diagnostics || []), failure.diagnostic]; + } +} + +function appendEvidenceCleanupWarning(receipt, publication) { + if (publication?.status !== 'committed-with-warning') return false; + const committedStatus = receipt.status; + const cleanupErrors = publication.cleanupErrors || []; + const cleanupMessage = publication.recoveryDirectory + ? `The evidence set was committed, but cleanup is incomplete. Recovery directory: "${publication.recoveryDirectory}".` + : 'The evidence set was committed, but cleanup is incomplete.'; + receipt.ok = false; + receipt.status = 'fail'; + receipt.error = receipt.error ? `${receipt.error} ${cleanupMessage}` : cleanupMessage; + receipt.publication = { + status: publication.status, + committedReceipt: publication.committedReceipt, + ...(publication.recoveryDirectory + ? { recoveryDirectory: publication.recoveryDirectory } : {}), + cleanupErrors, + committedStatus, + }; + receipt.diagnostics = [...(receipt.diagnostics || []), failureDiagnostic({ + code: 'viewer/evidence-cleanup-incomplete', + severity: 'warning', + message: cleanupMessage, + subject: { + artifact: receipt.artifact?.path, + receipt: publication.committedReceipt, + }, + evidence: { + publicationStatus: publication.status, + ...(publication.recoveryDirectory + ? { recoveryDirectory: publication.recoveryDirectory } : {}), + cleanupErrors, + }, + supportedFixes: publication.recoveryDirectory + ? [`inspect and safely retire the retained files under "${publication.recoveryDirectory}"`] + : ['resolve the reported filesystem cleanup failure before rerunning visual-check'], + })]; + return true; +} + +function publishReceiptOnly(artifactPath, outputs, receipt, ownership) { + const cleanupErrors = cleanupStagedEvidence(ownership); + if (cleanupErrors.length) { + const failure = evidenceWriteFailure( + artifactPath, + 'visual-check could not safely retire this run\'s incomplete staged evidence.', + cleanupErrors, + ); + cleanupStagedEvidence(ownership, { removeDirectory: true }); + ownership.active = false; + return failure; + } + try { + writeStagedEvidence(ownership, outputs.receipt, `${JSON.stringify(receipt, null, 2)}\n`); + } catch (error) { + const failure = evidenceWriteFailure( + artifactPath, + 'visual-check could not stage its evidence receipt.', + [{ file: stagedPathFor(ownership, outputs.receipt), reason: error.message }], + ); + cleanupStagedEvidence(ownership, { removeDirectory: true }); + ownership.active = false; + return failure; + } + return commitVisualEvidence(artifactPath, outputs, ownership, [outputs.receipt]); +} + +function persistBrowserEvidenceFailure( + artifactPath, + failure, + { outDir, compareSidecarParents = sameParent, ownership, command = 'visual-check', capture = true, captureViewports = CAPTURE_VIEWPORTS } = {}, +) { + const outputs = evidenceSidecarPaths(artifactPath, { outDir, command, capture, captureViewports }); + const directory = resolveSidecarDirectory(artifactPath, outputs, compareSidecarParents); + const capturedArtifact = readRegularFileContents(artifactPath, { + subject: 'failure-artifact', + }); + const artifactBytes = capturedArtifact.ok ? capturedArtifact.bytes : null; + const receipt = { + ...failure, + ok: false, status: 'fail', + ...(artifactBytes ? { + artifact: { + path: path.resolve(artifactPath), + sha256: sha256(artifactBytes), + bytes: artifactBytes.byteLength, + }, + } : {}), + containment: { + status: 'fail', + policy: 'fit-or-reader-declared-readable-vertical-scroll', + viewports: [], + }, + captures: { status: capture ? 'fail' : 'not-requested', screenshots: [], contactSheet: null }, + sidecars: { ...directory.sidecars, files: [] }, + }; + if (!directory.ok) { + receipt.error = directory.error; + receipt.diagnostics = [...(failure.diagnostics || []), directory.diagnostic]; + return receipt; + } + let activeOwnership = ownership; + if (!visualEvidenceOwnershipMatches(activeOwnership, artifactPath, outputs)) { + if (!artifactBytes) { + const conflict = evidencePathConflict(artifactPath, outputs, outputs.receipt, { + code: 'artifact-unreadable', + }); + receipt.error = conflict.error; + receipt.diagnostics = [...(failure.diagnostics || []), conflict.diagnostic]; + return receipt; + } + const preflight = beginVisualEvidenceWrite(artifactPath, artifactBytes, outputs); + if (!preflight.ok) { + receipt.error = preflight.error; + receipt.diagnostics = [...(failure.diagnostics || []), preflight.diagnostic]; + return receipt; + } + activeOwnership = preflight.ownership; + } + const publication = publishReceiptOnly(artifactPath, outputs, receipt, activeOwnership); + if (publication.ok) publishedBrowserEvidenceReceipts.add(receipt); + appendEvidencePublicationFailure(receipt, publication); + appendEvidenceCleanupWarning(receipt, publication); + return receipt; +} + +async function runBrowserEvidence({ + artifactPath, + outDir, + chromePath, + resolveChrome = findChrome, + browserFactory = async (resolvedChrome) => new ChromeVisualBrowser(resolvedChrome), + deliveryProvenance, + verifyArtifact, + compareSidecarParents = sameParent, + command, capture, +} = {}) { + if (!artifactPath) throw new Error(`${command} requires one delivered HTML artifact.`); + const artifact = path.resolve(artifactPath); + if (!/\.html?$/i.test(artifact)) throw new Error(`${command} requires an .html artifact.`); + const capturedArtifact = captureRegularFileBinding(artifact, { + subject: 'visual-check-artifact', + expectedLinks: 1, + includeContent: true, + }); + if (capturedArtifact.status !== 'captured') { + throw regularFileCaptureError('The visual-check artifact', capturedArtifact); + } + const artifactBytes = capturedArtifact.content.buffer; + const explainer = isExplainerHtml(artifactBytes.toString('utf8')); + const inspectionViewports = explainer ? EXPLAINER_VIEWPORTS : VISUAL_CHECK_VIEWPORTS; + const captureViewports = explainer ? EXPLAINER_VIEWPORTS : CAPTURE_VIEWPORTS; + const observe = explainer ? explainerObservation : observation; + try { + const outputs = evidenceSidecarPaths(artifact, { outDir, command, capture, captureViewports }); + const directory = resolveSidecarDirectory(artifact, outputs, compareSidecarParents); + const receipt = baseReceipt({ + artifactPath: artifact, + artifact: artifactBytes, + sidecars: directory.sidecars, + chrome: { status: 'not-checked', executable: null }, + deliveryProvenance, command, capture, + }); + if (explainer) { + receipt.family = 'explainer'; + receipt.viewportPolicy = 'explainer-desktop-v1'; + receipt.containment.policy = 'horizontal-containment-readable-document-scroll'; + } + if (!directory.ok) { + receipt.error = directory.error; + receipt.diagnostics = [directory.diagnostic]; + return { exitCode: EXIT.fail, receipt }; + } + const preflight = beginVisualEvidenceWrite(artifact, artifactBytes, outputs); + if (!preflight.ok) { + receipt.error = preflight.error; + receipt.diagnostics = [preflight.diagnostic]; + return { exitCode: EXIT.fail, receipt }; + } + const ownership = preflight.ownership; + let inspectionArtifact; + try { + // Chrome's file loader cannot reliably open long Windows/UNC paths. Keep + // the one inspected snapshot local; publish evidence on its target volume. + ownership.inspection = { + directory: fs.mkdtempSync(path.join(os.tmpdir(), 'archify-inspection-')), + }; + const stat = fs.lstatSync(ownership.inspection.directory, { bigint: true }); + if (!stat.isDirectory() || stat.isSymbolicLink() || stat.ino === 0n) { + throw new Error('the private inspection directory has no stable identity'); + } + ownership.inspection.identity = fileIdentity(stat); + inspectionArtifact = path.join(ownership.inspection.directory, 'artifact-snapshot.html'); + ownership.stagingPaths.set(artifact, inspectionArtifact); + writeStagedEvidence(ownership, artifact, artifactBytes); + } catch (error) { + return { exitCode: EXIT.fail, receipt: persistBrowserEvidenceFailure(artifact, { + ...receipt, + status: 'fail', + ok: false, + error: `visual-check could not stage its private artifact snapshot: ${error.message}`, + diagnostics: [failureDiagnostic({ + code: 'viewer/artifact-snapshot', + message: 'visual-check could not bind a private artifact snapshot.', + subject: { artifact }, + evidence: { reason: error.message }, + supportedFixes: ['retry after resolving the reported staging filesystem error'], + })], + }, { outDir, compareSidecarParents, ownership, command, capture, captureViewports }) }; + } + const verifyInspectionArtifact = () => { + const entry = ownership.stagedEntries.get(artifact); + if (!entry || !currentEvidenceMatches(entry.path, entry.identity, entry.evidence)) { + throw new Error('The private visual-check artifact snapshot changed during inspection.'); + } + }; + try { + verifyArtifact?.(artifactBytes); + } catch (error) { + return { exitCode: EXIT.fail, receipt: persistBrowserEvidenceFailure(artifact, { + schemaVersion: 1, command, evidenceKind: 'automated-browser', visualReview: capture ? 'pending' : 'not-requested', + artifact: { path: artifact, sha256: sha256(artifactBytes), bytes: artifactBytes.byteLength }, + provenance: error.deliveryProvenance?.status, error: error.message, + diagnostics: error.archifyDiagnostics || [], + }, { outDir, compareSidecarParents, ownership, command, capture, captureViewports }) }; + } + + const resolvedChrome = chromePath || resolveChrome(); + receipt.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.themeStates.status = 'skipped'; + if (capture) receipt.captures.status = 'skipped'; + receipt.error = 'No supported browser found. Install Chrome/Chromium (or Microsoft Edge), or 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/Chromium executable (or Microsoft Edge) and rerun ${command}`], + })]; + const publication = publishReceiptOnly(artifact, outputs, receipt, ownership); + if (publication.ok) publishedBrowserEvidenceReceipts.add(receipt); + if (!publication.ok) { + appendEvidencePublicationFailure(receipt, publication); + return { exitCode: EXIT.fail, receipt }; + } + if (appendEvidenceCleanupWarning(receipt, publication)) { + return { exitCode: EXIT.fail, 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, index) => [ + screenshotKey(entry.width, entry.height, entry.theme), + { ...entry, stagedPath: ownership.stagedOutputs.screenshots[index].path }, + ])); + + try { + const inspect = (options) => explainer ? inspectExplainerBrowser(browser, options) : browser.inspect(options); + for (const viewport of inspectionViewports) { + const key = screenshotKey(viewport.width, viewport.height, 'light'); + const screenshot = screenshotsByKey.get(key); + const metrics = await inspect({ + artifactPath: inspectionArtifact, + ...viewport, + theme: 'light', + ...(screenshot ? { + screenshotPath: screenshot.stagedPath, + writeScreenshot: (bytes) => writeStagedEvidence(ownership, screenshot.path, bytes), + } : {}), + }); + verifyInspectionArtifact(); + if (screenshot) { + if (screenshot && !ownership.stagedEntries.has(screenshot.path)) { + registerStagedEvidence(ownership, screenshot.path); + } else { + const staged = ownership.stagedEntries.get(screenshot.path); + if (!currentEvidenceMatches(staged.path, staged.identity, staged.evidence)) { + throw new Error('The staged visual-check screenshot changed after capture.'); + } + } + } + observations.set(key, observe({ ...viewport, theme: 'light', metrics })); + } + for (const viewport of captureViewports) { + const key = screenshotKey(viewport.width, viewport.height, 'dark'); + const screenshot = screenshotsByKey.get(key); + const metrics = await inspect({ + artifactPath: inspectionArtifact, + ...viewport, + theme: 'dark', + ...(screenshot ? { + screenshotPath: screenshot.stagedPath, + writeScreenshot: (bytes) => writeStagedEvidence(ownership, screenshot.path, bytes), + } : {}), + }); + verifyInspectionArtifact(); + if (screenshot && !ownership.stagedEntries.has(screenshot.path)) { + registerStagedEvidence(ownership, screenshot.path); + } else if (screenshot) { + const staged = ownership.stagedEntries.get(screenshot.path); + if (!currentEvidenceMatches(staged.path, staged.identity, staged.evidence)) { + throw new Error('The staged visual-check screenshot changed after capture.'); + } + } + observations.set(key, observe({ ...viewport, theme: 'dark', metrics })); + } + + } finally { + if (browser?.close) await browser.close(); + } + + let artifactVerification = verifyRegularFileBinding(capturedArtifact.binding); + if (artifactVerification.status !== 'match') { + throw regularFileCaptureError(`The delivered artifact changed while ${command} was running`, artifactVerification); + } + verifyArtifact?.(artifactBytes); + artifactVerification = verifyRegularFileBinding(capturedArtifact.binding); + if (artifactVerification.status !== 'match') { + throw regularFileCaptureError(`The delivered artifact changed while ${command} was running`, artifactVerification); + } + + receipt.containment.viewports = inspectionViewports.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()]; + receipt.themeStates.viewports = allObservations.map((entry) => ({ + width: entry.width, + height: entry.height, + requestedTheme: entry.theme, + resolvedTheme: entry.resolvedTheme, + ok: entry.resolvedTheme === entry.theme, + })); + const themeStatePass = receipt.themeStates.viewports.every(entry => entry.ok); + 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 = (explainer ? explainerObservationDiagnostics : observationDiagnostics)({ + artifact, + allObservations, + readabilityObservations: receipt.readability.viewports, command, + }); + receipt.themeStates.status = themeStatePass ? 'pass' : 'fail'; + receipt.containment.status = containmentPass ? 'pass' : 'fail'; + receipt.readability.status = readabilityPass ? 'pass' : 'fail'; + receipt.viewerChrome.status = viewerChromePass ? 'pass' : 'fail'; + if (capture) { + receipt.captures.status = 'pass'; + receipt.captures.contactSheet = path.basename(outputs.contactSheet); + } + if (explainer) { + receipt.containment.viewports = allObservations.map(entry => ({ ...entry })); + receipt.readability.viewports = allObservations.map(entry => ({ ...entry })); + receipt.viewerChrome.viewports = allObservations.map(entry => ({ ...entry })); + receipt.commonQa = { status: allObservations.every(e => e.consoleErrors.length === 0 && e.duplicateIds.length === 0 && e.keyboardIssues.length === 0 && e.keyboardTraversal?.ok) ? 'pass' : 'fail', observations: allObservations.map(e => ({width:e.width,theme:e.theme,ok:e.consoleErrors.length===0&&e.duplicateIds.length===0&&e.keyboardIssues.length===0&&e.keyboardTraversal?.ok===true})) }; + receipt.interactions = { status: allObservations.every(e => e.interactionFailures.length === 0) ? 'pass' : 'fail', observations: allObservations.map(e => ({width:e.width,theme:e.theme,ok:e.interactionFailures.length===0,failures:e.interactionFailures})) }; + receipt.references = { status: allObservations.every(e => e.references.length === 0) ? 'pass' : 'fail', observations: allObservations.map(e => ({width:e.width,theme:e.theme,ok:e.references.length===0,failures:e.references})) }; + receipt.noJavaScript = { status: allObservations.every(e => e.noJavaScript.every(n => n.ok)) ? 'pass' : 'fail', observations: allObservations.map(e => ({width:e.width,theme:e.theme,ok:e.noJavaScript.every(n=>n.ok),states:e.noJavaScript})) }; + receipt.print = { status: allObservations.every(e => e.print.ok) ? 'pass' : 'fail', observations: allObservations.map(e => ({width:e.width,...e.print})) }; + } + receipt.ok = containmentPass && themeStatePass && readabilityPass && viewerChromePass && (!explainer || receipt.diagnostics.length === 0); + receipt.status = receipt.ok ? 'pass' : 'fail'; + if (capture) writeStagedEvidence(ownership, outputs.contactSheet, contactSheetHtml({ + artifactPath: artifact, + receipt, + screenshots: receipt.captures.screenshots, + })); + recordVisualEvidenceFiles(receipt, outputs, ownership); + writeStagedEvidence(ownership, outputs.receipt, `${JSON.stringify(receipt, null, 2)}\n`); + const publication = commitVisualEvidence( + artifact, + outputs, + ownership, + visualEvidenceTargets(outputs).map((target) => target.path), + ); + if (!publication.ok) { + appendEvidencePublicationFailure(receipt, publication); + receipt.ok = false; + receipt.status = 'fail'; + return { exitCode: EXIT.fail, receipt }; + } + publishedBrowserEvidenceReceipts.add(receipt); + if (appendEvidenceCleanupWarning(receipt, publication)) { + return { exitCode: EXIT.fail, receipt }; + } + return { exitCode: receipt.ok ? EXIT.pass : EXIT.fail, receipt }; + } catch (error) { + const startupTimeout = error.code === 'ERR_CHROME_CDP_TIMEOUT' + && error.method === 'Target.getTargets'; + receipt.status = 'fail'; + receipt.ok = false; + receipt.error = error.message; + receipt.containment.status = 'fail'; + receipt.readability.status = 'fail'; + receipt.viewerChrome.status = 'fail'; + receipt.themeStates.status = 'fail'; + receipt.captures.status = capture ? 'fail' : 'not-requested'; + receipt.captures.screenshots = []; + receipt.captures.contactSheet = null; + if (error.deliveryProvenance) receipt.provenance = error.deliveryProvenance.status; + receipt.diagnostics = error.archifyDiagnostics || [failureDiagnostic({ + code: error.code === 'ARCHIFY_TEMP_CLEANUP' ? 'viewer/temp-cleanup-incomplete' + : startupTimeout ? 'viewer/chrome-startup-timeout' : `viewer/${command}-runtime`, + message: error.code === 'ARCHIFY_TEMP_CLEANUP' + ? 'Browser cleanup could not safely finish; inspect the reported retained path.' + : startupTimeout + ? 'Chrome did not finish its initial DevTools handshake within the startup window.' + : `${command} could not complete its Chrome inspection.`, + subject: { artifact }, + evidence: { reason: error.message, ...(error.directory ? { retainedDirectory: error.directory } : {}) }, + supportedFixes: error.code === 'ARCHIFY_TEMP_CLEANUP' + ? ['resolve the reported process or filesystem failure; verify directory ownership before manual cleanup'] + : startupTimeout + ? [ + 'do not edit or simplify the artifact because this is a browser-startup failure', + `retry ${command} once after host load subsides; if it repeats, stop and report the environment failure`, + ] + : [`resolve the reported Chrome inspection error, then rerun ${command}`], + })]; + return { + exitCode: EXIT.fail, + receipt: persistBrowserEvidenceFailure(artifact, receipt, { + outDir, + compareSidecarParents, + ownership, command, capture, captureViewports, + }), + }; + } + } finally { + releaseRegularFileBinding(capturedArtifact.binding); + } +} + +export function persistVisualCheckFailure(artifactPath, failure, options = {}) { + return persistBrowserEvidenceFailure(artifactPath, failure, { ...options, command: 'visual-check', capture: true }); +} +export function persistBrowserCheckFailure(artifactPath, failure, options = {}) { + return persistBrowserEvidenceFailure(artifactPath, failure, { ...options, command: 'browser-check', capture: false }); +} +export function runVisualCheck(options = {}) { + return runBrowserEvidence({ ...options, command: 'visual-check', capture: true }); +} +export function runBrowserCheck(options = {}) { + return runBrowserEvidence({ ...options, command: 'browser-check', capture: false }); +} diff --git a/ui-plugins/archify-panel/vendor/archify/brand-marks/README.md b/ui-plugins/archify-panel/vendor/archify/brand-marks/README.md new file mode 100644 index 00000000..83578f6f --- /dev/null +++ b/ui-plugins/archify-panel/vendor/archify/brand-marks/README.md @@ -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 --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`. diff --git a/ui-plugins/archify-panel/vendor/archify/brand-marks/catalog.json b/ui-plugins/archify-panel/vendor/archify/brand-marks/catalog.json new file mode 100644 index 00000000..ac569893 --- /dev/null +++ b/ui-plugins/archify-panel/vendor/archify/brand-marks/catalog.json @@ -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"] } + ] +} diff --git a/ui-plugins/archify-panel/vendor/archify/delta/architecture-delta.mjs b/ui-plugins/archify-panel/vendor/archify/delta/architecture-delta.mjs new file mode 100644 index 00000000..6f3180a5 --- /dev/null +++ b/ui-plugins/archify-panel/vendor/archify/delta/architecture-delta.mjs @@ -0,0 +1,1318 @@ +import { softwareNoticesHtml } from '../renderers/shared/software-notices.mjs'; +import { parseRepositoryRemote } from '../renderers/shared/repository-location.mjs'; + +const COMPARATOR_VERSION = 1; +const CANONICAL_VERSION = 1; + +export class ArchitectureDeltaError extends Error { + constructor(code, message, details = {}) { + super(message); + this.name = 'ArchitectureDeltaError'; + this.code = code; + this.details = details; + } +} + +const codepointOrder = (left, right) => (left < right ? -1 : left > right ? 1 : 0); +const sorted = (values) => [...values].sort((left, right) => codepointOrder(String(left), String(right))); + +function canonical(value) { + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort(codepointOrder).map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`; + } + return JSON.stringify(value); +} + +const equal = (left, right) => canonical(left) === canonical(right); + +function sortedObjects(values) { + return [...values].sort((left, right) => codepointOrder(canonical(left), canonical(right))); +} + +function sortedBy(values, keyFor) { + return [...values].sort((left, right) => codepointOrder(String(keyFor(left)), String(keyFor(right)))); +} + +function normalizeRepository(repository) { + if (!repository) return undefined; + const location = parseRepositoryRemote(repository.url, { authored: true, provider: repository.provider }); + const url = location?.url || String(repository.url || ''); + return { + url: location?.provider === 'github' || location?.provider === 'gitlab' ? url.toLowerCase() : url, + revision: String(repository.revision || '').toLowerCase(), + ...(repository.provider !== undefined ? { provider: repository.provider } : {}), + ...(repository.link_mode !== undefined ? { link_mode: repository.link_mode } : {}), + }; +} + +const PROVENANCE_FIELDS = ['url', 'revision', 'provider', 'link_mode']; + +function provenanceSide(repository) { + if (!repository) return null; + return { + revision: repository.revision, + ...(repository.provider !== undefined ? { provider: repository.provider } : {}), + ...(repository.link_mode !== undefined ? { linkMode: repository.link_mode } : {}), + }; +} + +function provenanceChange(base, head) { + if (equal(base, head)) return undefined; + const changedFields = !base || !head + ? ['/repository'] + : PROVENANCE_FIELDS + .filter((field) => !equal(base[field], head[field])) + .map((field) => `/${field}`) + .sort(codepointOrder); + return { + changedFields, + base: provenanceSide(base), + head: provenanceSide(head), + }; +} + +function normalizeComponent(component) { + return { + ...component, + ...(Array.isArray(component.sources) ? { sources: sortedObjects(component.sources) } : {}), + }; +} + +function normalizeBoundary(boundary) { + return { ...boundary, wraps: sorted(boundary.wraps || []) }; +} + +export function canonicalArchitecture(diagram) { + const meta = { ...(diagram.meta || {}) }; + delete meta.output; + if (meta.repository) meta.repository = normalizeRepository(meta.repository); + return { + schema_version: diagram.schema_version, + diagram_type: diagram.diagram_type, + meta, + ...(diagram.layout ? { layout: diagram.layout } : {}), + components: sortedBy((diagram.components || []).map(normalizeComponent), (component) => component.id), + boundaries: sortedBy((diagram.boundaries || []).map(normalizeBoundary), boundaryKey), + connections: sortedBy(diagram.connections || [], (connection) => connection.id || ''), + ...(diagram.cards ? { cards: diagram.cards } : {}), + }; +} + +export function canonicalArchitectureJson(diagram) { + return canonical(canonicalArchitecture(diagram)); +} + +function fail(code, message, details) { + throw new ArchitectureDeltaError(code, message, details); +} + +function requireComparableShape(diagram, side) { + if (diagram?.schema_version !== 1) { + fail('delta/schema-version-mismatch', `${side} must use schema_version 1.`, { side, path: '/schema_version', actual: diagram?.schema_version }); + } + if (diagram?.diagram_type !== 'architecture') { + fail('delta/type-mismatch', `${side} must use diagram_type architecture.`, { side, path: '/diagram_type', actual: diagram?.diagram_type }); + } +} + +function stableIndex(items, collection, side, missingCode = 'delta/stable-id-required') { + const index = new Map(); + const missing = []; + const duplicates = []; + (items || []).forEach((item, itemIndex) => { + if (!item?.id) missing.push(`/${collection}/${itemIndex}/id`); + else if (index.has(item.id)) duplicates.push(item.id); + else index.set(item.id, item); + }); + if (missing.length) { + fail(missingCode, `${side} ${collection} require authored stable ids for comparison.`, { + side, + paths: sorted(missing), + supportedFixes: [`add a unique id to every ${collection} item`], + }); + } + if (duplicates.length) { + fail('delta/duplicate-stable-id', `${side} ${collection} contain duplicate ids.`, { + side, + collection, + ids: sorted(new Set(duplicates)), + supportedFixes: [`make every ${collection} id unique`], + }); + } + return index; +} + +const boundaryKey = (boundary) => `${boundary.kind}\u001f${boundary.label}`; + +function boundaryIndex(boundaries, side) { + const index = new Map(); + const ambiguous = []; + for (const boundary of boundaries || []) { + const key = boundaryKey(boundary); + if (index.has(key)) ambiguous.push(`${boundary.kind}:${boundary.label}`); + else index.set(key, boundary); + } + if (ambiguous.length) { + fail('delta/boundary-key-ambiguous', `${side} boundary kind + label keys must be unique.`, { + side, + boundaries: sorted(new Set(ambiguous)), + supportedFixes: ['rename one duplicate boundary or add stable boundary ids in a future schema version'], + }); + } + return index; +} + +function normalizedField(item, field) { + const value = item?.[field]; + if (field === 'sources' && Array.isArray(value)) return sortedObjects(value); + if (field === 'wraps' && Array.isArray(value)) return sorted(value); + return value; +} + +function fieldChanges(before, after, groups) { + const classifications = []; + const changedFields = []; + for (const [classification, fields] of Object.entries(groups)) { + const changed = fields.filter((field) => !equal(normalizedField(before, field), normalizedField(after, field))); + if (changed.length) classifications.push(classification); + changedFields.push(...changed.map((field) => `/${field}`)); + } + return { classifications: sorted(classifications), changedFields: sorted(changedFields) }; +} + +const COMPONENT_FIELDS = { + semantic: ['type', 'label', 'sublabel', 'tag', 'brand', 'icon'], + evidence: ['sources'], + geometry: ['row', 'col', 'pos', 'size'], +}; +const CONNECTION_FIELDS = { + topology: ['from', 'to'], + semantic: ['label', 'variant'], + geometry: ['fromSide', 'toSide', 'route', 'via', 'labelAt', 'labelDx', 'labelDy', 'labelSegment', 'width'], +}; +const BOUNDARY_FIELDS = { scope: ['wraps'], geometry: ['pad'] }; + +function statusFor(classifications, kind) { + if (classifications.some((value) => ['topology', 'semantic', 'scope'].includes(value))) return 'changed'; + if (classifications.includes('evidence')) return 'evidence-changed'; + if (classifications.includes('geometry')) return kind === 'connection' ? 'rerouted' : kind === 'component' ? 'moved' : 'geometry-changed'; + return 'same'; +} + +function compareEntities(baseIndex, headIndex, kind, groups, describe) { + const changes = []; + const identityClassification = kind === 'connection' ? 'topology' : kind === 'boundary' ? 'scope' : 'semantic'; + for (const id of sorted(new Set([...baseIndex.keys(), ...headIndex.keys()]))) { + const base = baseIndex.get(id); + const head = headIndex.get(id); + if (!base) changes.push({ ...describe(id, undefined, head), status: 'added', classifications: [identityClassification], changedFields: [] }); + else if (!head) changes.push({ ...describe(id, base, undefined), status: 'removed', classifications: [identityClassification], changedFields: [] }); + else { + const fields = fieldChanges(base, head, groups); + const status = statusFor(fields.classifications, kind); + if (status !== 'same') changes.push({ ...describe(id, base, head), status, ...fields }); + } + } + return changes; +} + +function summaryFor(changes, shape) { + const summary = Object.fromEntries(shape.map((key) => [key, 0])); + for (const change of changes) { + const key = change.status.replace(/-([a-z])/g, (_all, letter) => letter.toUpperCase()); + if (Object.hasOwn(summary, key)) summary[key] += 1; + } + return summary; +} + +function presentationChanged(base, head) { + const basePresentation = { + title: base.meta?.title, + locale: base.meta?.locale, + subtitle: base.meta?.subtitle, + animation: base.meta?.animation, + visual_preset: base.meta?.visual_preset, + quality_profile: base.meta?.quality_profile, + engineering_profile: base.meta?.engineering_profile, + legend: base.meta?.legend, + viewBox: base.meta?.viewBox, + layout: base.layout, + cards: base.cards, + }; + const headPresentation = { + title: head.meta?.title, + locale: head.meta?.locale, + subtitle: head.meta?.subtitle, + animation: head.meta?.animation, + visual_preset: head.meta?.visual_preset, + quality_profile: head.meta?.quality_profile, + engineering_profile: head.meta?.engineering_profile, + legend: head.meta?.legend, + viewBox: head.meta?.viewBox, + layout: head.layout, + cards: head.cards, + }; + return !equal(basePresentation, headPresentation); +} + +export function compareArchitecture(base, head, evidence = {}) { + requireComparableShape(base, 'base'); + requireComparableShape(head, 'head'); + const baseComponents = stableIndex(base.components, 'components', 'base'); + const headComponents = stableIndex(head.components, 'components', 'head'); + const shared = sorted([...baseComponents.keys()].filter((id) => headComponents.has(id))); + if (!shared.length) { + fail('delta/no-shared-component-id', 'The snapshots share no component id, so Archify cannot prove that they describe the same system.', { + supportedFixes: ['preserve at least one authored component id across snapshots'], + }); + } + + const baseConnections = stableIndex(base.connections, 'connections', 'base', 'delta/relationship-id-required'); + const headConnections = stableIndex(head.connections, 'connections', 'head', 'delta/relationship-id-required'); + const baseBoundaries = boundaryIndex(base.boundaries, 'base'); + const headBoundaries = boundaryIndex(head.boundaries, 'head'); + + const baseRepository = normalizeRepository(base.meta?.repository); + const headRepository = normalizeRepository(head.meta?.repository); + const identity = (repository) => parseRepositoryRemote(repository.url, { authored: true, provider: repository.provider })?.identity || repository.url; + if (baseRepository && headRepository && identity(baseRepository) !== identity(headRepository)) { + fail('delta/repository-mismatch', 'The snapshots name different repositories.', { + baseRepository: baseRepository.url, + headRepository: headRepository.url, + supportedFixes: ['compare snapshots from the same repository or remove repository evidence from both inputs'], + }); + } + const proofLevel = baseRepository && headRepository + && evidence.baseVerified && evidence.headVerified + && /^[a-f0-9]{40}$/.test(baseRepository.revision) + && /^[a-f0-9]{40}$/.test(headRepository.revision) + ? 'revision-pinned' + : 'authored'; + + const components = compareEntities(baseComponents, headComponents, 'component', COMPONENT_FIELDS, (id, before, after) => ({ + id, + baseLabel: before?.label, + headLabel: after?.label, + })); + const connections = compareEntities(baseConnections, headConnections, 'connection', CONNECTION_FIELDS, (id, before, after) => ({ + id, + ...(before ? { base: { from: before.from, to: before.to, label: before.label || '' } } : {}), + ...(after ? { head: { from: after.from, to: after.to, label: after.label || '' } } : {}), + })); + const boundaries = compareEntities(baseBoundaries, headBoundaries, 'boundary', BOUNDARY_FIELDS, (_key, before, after) => ({ + key: `${(after || before).kind}:${(after || before).label}`, + kind: (after || before).kind, + label: (after || before).label, + })); + const provenance = provenanceChange(baseRepository, headRepository); + const provenanceChanged = Boolean(provenance); + + return { + schemaVersion: 1, + ok: true, + command: 'compare', + type: 'architecture', + comparatorVersion: COMPARATOR_VERSION, + canonicalVersion: CANONICAL_VERSION, + completeness: 'complete', + proofLevel, + base: { + title: base.meta?.title || '', + ...(evidence.baseRawSha256 ? { rawSha256: evidence.baseRawSha256 } : {}), + ...(evidence.baseSemanticSha256 ? { semanticSha256: evidence.baseSemanticSha256 } : {}), + ...(Number.isInteger(evidence.baseBytes) ? { bytes: evidence.baseBytes } : {}), + ...(baseRepository?.revision ? { revision: baseRepository.revision } : {}), + }, + head: { + title: head.meta?.title || '', + ...(evidence.headRawSha256 ? { rawSha256: evidence.headRawSha256 } : {}), + ...(evidence.headSemanticSha256 ? { semanticSha256: evidence.headSemanticSha256 } : {}), + ...(Number.isInteger(evidence.headBytes) ? { bytes: evidence.headBytes } : {}), + ...(headRepository?.revision ? { revision: headRepository.revision } : {}), + }, + summary: { + components: summaryFor(components, ['added', 'changed', 'evidenceChanged', 'removed', 'moved']), + connections: summaryFor(connections, ['added', 'changed', 'removed', 'rerouted']), + boundaries: summaryFor(boundaries, ['added', 'changed', 'removed', 'geometryChanged']), + presentationChanged: presentationChanged(base, head), + provenanceChanged, + }, + changes: { components, connections, boundaries }, + ...(provenance ? { provenance } : {}), + identity: { + components: 'components[].id', + connections: 'connections[].id (required)', + boundaries: 'boundaries[].kind + boundaries[].label (derived)', + }, + view: { visualPreset: head.meta?.visual_preset || 'classic' }, + limitations: [ + 'Authored Architecture IR only; no runtime impact, causality, risk, or mergeability is inferred.', + 'Boundary identity is conservatively derived from kind + label.', + ], + }; +} + +function esc(value) { + return String(value ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); +} + +const safeJson = (value) => JSON.stringify(value, null, 2).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026'); + +export function extractArchitectureSvg(html) { + const match = html.match(//); + if (!match) fail('delta/svg-missing', 'A validated Architecture artifact did not contain its primary SVG.'); + return match[0]; +} + +export function extractArtifactCss(html) { + const match = html.match(/Repository provenance changed${esc(provenanceDescription)}` + : ''; + const rowHtml = rows.length ? rows.map((row, index) => { + const label = row.headLabel || row.baseLabel || row.head?.label || row.base?.label || row.label || row.id; + const targetSignature = expectedReviewTargetSignature(row); + return `
  • `; + }).join('\n') : provenanceChanged + ? '
  • No component, relationship, or boundary changes; repository provenance changed.
  • ' + : '
  • No authored architecture changes.
  • '; + const baseView = baseHtml + ? `` + : baseSvg; + const headView = headHtml + ? `` + : headSvg; + const html = ` + +${softwareNoticesHtml}${esc(receipt.head.title)} Architecture Delta + +

    ARCHITECTURE DELTA · ${proof}

    See what changed
    before you merge.

    ${esc(receipt.base.title)} → ${esc(receipt.head.title)}

    ${total(receipt.summary, 'added')}ADDED
    ${total(receipt.summary, 'removed')}REMOVED
    ${changed}CHANGED
    ${provenanceNotice} +
    + ADD− DEL~ MOD↔ MOVE
    + +
    ${deltaSvg}
    +Exact authored changes · ${rows.length}
      ${rowHtml}
    +
    Stable IDs only · completeness: complete · ${proof}Authored IR only · no risk or mergeability inference
    + +`; + return html.replace(/[ \t]+$/gm, ''); +} + +export function validateArchitectureDeltaHtml(html, receipt) { + const failures = []; + const rows = architectureDeltaChangeRows(receipt); + const deltaMarkup = html.match(/
    ([\s\S]*?)<\/section>/)?.[1] || ''; + const svgTags = [...deltaMarkup.matchAll(/<\/?svg\b[^>]*>/g)]; + let svgDepth = 0; + let svgRoots = 0; + let rootStart = -1; + let rootEnd = -1; + let svgBalanced = true; + for (const match of svgTags) { + if (match[0].startsWith(' (html.match(new RegExp(`
    0; + const provenanceDescription = provenanceChanged && (legacyProvenance || validProvenanceFields) ? provenanceDetail(receipt.provenance) : ''; + const provenanceFieldLabel = legacyProvenance ? 'repository metadata' : validProvenanceFields ? provenanceFields.join(', ') : ''; + if (provenanceNotices !== (provenanceChanged ? 1 : 0) + || (!provenanceChanged && !legacyProvenance) + || (provenanceChanged && !legacyProvenance && !validProvenanceFields) + || (provenanceChanged && !html.includes(`data-provenance-fields="${esc(provenanceFieldLabel)}"`)) + || (provenanceChanged && !html.includes(`${esc(provenanceDescription)}`))) { + failures.push('provenance change notice does not match the receipt'); + } + if (provenanceChanged && rows.length === 0 && html.includes('No authored architecture changes.')) failures.push('provenance-only delta claims no authored changes'); + if (!html.includes('aria-label="Authored change review"')) failures.push('missing exact-ID change navigator'); + if ((html.match(/class="change-row"/g) || []).length !== rows.length) failures.push('change navigator row count does not match the receipt'); + if (!html.includes('id="export-svg"') || !html.includes('id="share-card"') + || !html.includes('window.Archify.deltaExport = { canonicalSvg: canonicalDeltaSvg, shareCard')) { + failures.push('missing canonical Delta SVG or Share Card export contract'); + } + for (const [index, row] of rows.entries()) { + const safeKey = esc(row.key).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const rowMatches = [...html.matchAll(new RegExp(`