diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..b3b23ec --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,56 @@ +# Product + +## Register + +product + +## Users + +Engineers using Simplify Codebase in a desktop coding-agent workflow. They need +to locate accidental complexity, understand confirmed ownership and consumer +paths, judge a proposed cut, and verify a completed structural change. They +need a concise analysis entry point before they can interpret the visual; they +do not need the full proof record duplicated inside it. + +## Product Purpose + +Turn a proved Simplify Codebase Finding into a direct visual investigation +surface. Success means the user can see where the problem lives, what reaches +it, what would disappear, and what survived, while the written proof record +remains the authoritative detailed report. + +## Brand Personality + +Direct, evidential, technical. Preserve Archify's crafted engineering-diagram +identity while making cleanup decisions feel purpose-built rather than added on. + +## Anti-references + +- explanatory card walls that repeat what the graph already shows; +- generic SaaS dashboards and decorative metrics; +- cleanup actions hidden behind general diagram controls; +- an Archify clone that lacks Simplify Codebase semantics; +- dense prose permanently occupying the investigation canvas. + +## Design Principles + +- Let the graph own topology and state; use concise text to orient and + interpret it. +- Make Locate, Trace, Cut, and Decide the visible primary workflow. +- Keep the Finding summary visible once, then reveal proof, consequence, and + uncertainty at the decision point. +- Keep the graph dominant when evidence opens; decision text belongs beside + the graph instead of pushing it below the fold. +- De-emphasize contextual semantics in readable gray instead of hiding them. +- Load into a stable graph. Motion may explain an explicit stage change, but + never pulse or scan the report decoratively on entry. +- Keep source loci and stable Finding IDs available throughout the investigation. +- Make node selection unmistakable and preserve Archify's native focus links. +- Never imply that authored reachability proves runtime impact or deletion safety. + +## Accessibility & Inclusion + +Preserve keyboard navigation, visible focus, reduced-motion behavior, and the +existing Archify semantic labels. Never use color as the only signal for node +role, cut state, confidence, or risk. The visualization is intentionally designed +and accepted for desktop viewports only. diff --git a/README.en.md b/README.en.md index 356014c..262f425 100644 --- a/README.en.md +++ b/README.en.md @@ -34,6 +34,7 @@ Static analysis can surface leads, but it cannot prove a deletion safe by itself Every serious candidate receives a proof record covering: +- the exact ownership boundary, symbol, file, and verified source range where available; - the maintenance burden it creates; - production, test, dynamic, and external consumers; - the complete cut, including candidate-owned members inside shared files; @@ -71,6 +72,8 @@ git clone https://github.com/tt-a1i/simplify-codebase.git \ Start a new task after installation so the Skill catalog refreshes. For other Agent environments that support `SKILL.md`, place the repository in that environment's Skill directory. +The interactive Cleanup Map is bundled with this Skill; Archify does not need to be installed separately. It vendors a trimmed Architecture renderer and desktop interaction core, then adds cleanup-specific compilation and Survey/Change behavior. The renderer requires Node.js 18 or newer, has no npm package dependency, and produces HTML without external font requests. + ## Use ### Audit a repository without editing it @@ -97,25 +100,43 @@ Use $simplify-codebase to remove one high-confidence source of accidental comple Use $simplify-codebase to verify and integrate the simplification findings from this PR. Preserve evidence, not finding counts. ``` +### Add a visual companion + +```text +Use $simplify-codebase to audit this repository. Keep the written proof records authoritative. When several confirmed components and consumers make a finding hard to locate, also deliver a Cleanup Map with deep links for each visualized Finding ID. Do not call authored graph reachability runtime impact. +``` + ## What it returns A read-only survey returns coverage, ranked proof records, important counterexamples, unresolved questions, and the next fact needed for each uncertainty. A change task also returns the implemented cut, validation results by layer, remaining risk, an operation receipt, and an executable undo path. A narrow green check is never presented as complete runtime or user acceptance. +When a finding crosses several components, states, or consumers, the Skill can use its bundled renderer to deliver a validated desktop interactive HTML artifact. Survey follows Locate, Trace, Cut, and Decide; Change follows Before, Cut, After, and Verify. The map remains a visual companion to the proof record, never a substitute for consumer evidence, the Change operation receipt, or the undo path. When topology remains unresolved or a map adds no explanatory value, the result falls back to the complete text report with exact source locations. + ## Repository layout ```text . ├── SKILL.md # Core workflow and decision rules +├── PRODUCT.md # Visualization product and disclosure principles ├── agents/openai.yaml # Agent-facing metadata ├── references/ │ ├── investigation.md # Broad investigation and discovery │ ├── boundaries-and-lifecycle.md │ ├── execution-and-recovery.md │ ├── decision-records.md -│ └── integrating-findings.md +│ ├── integrating-findings.md +│ └── visual-reporting.md # Truth and delivery contract for optional visuals +├── visualization/ +│ ├── cleanup-map.schema.json # Cleanup-specific semantic contract +│ ├── render-cleanup-map.mjs # Cleanup Map to Archify Architecture compiler +│ ├── archify-core/ # Vendored Architecture renderer and desktop viewer +│ ├── cleanup-extension.* # Survey and Change interaction extension +│ ├── examples/ # Survey and Change inputs +│ └── test/ # Contract, route, and artifact tests ├── docs/validation.md # Behavioral validation evidence +├── docs/visual-report-example.md └── assets/hero.png # Original hero artwork ``` @@ -123,6 +144,8 @@ A change task also returns the implemented cut, validation results by layer, rem This version has been exercised in Change, Broad, Integration, and Decision-record scenarios, including a full survey of a 973-file Python + TypeScript project. See [docs/validation.md](./docs/validation.md) for the method and known limits. +The visual companion directly vendors Archify's Architecture renderer, Signal Flow visual system, and desktop viewer runtime, then adds Findings, Survey/Change stages, cut boundaries, and an on-demand evidence drawer. The default surface first uses a concise analysis finding to orient the user, then discloses source, route, and decision evidence with the active stage while the graph keeps the primary visual space. Other general diagram renderers, the repository CLI, publishing, and gallery flows are not included. Attribution, adaptation notes, and the MIT license are preserved under [`visualization/`](./visualization/). See [docs/visual-report-example.md](./docs/visual-report-example.md) for the handoff format. + The Skill does not replace product judgment. Removing a reachable capability, supported interface, persisted representation, or compatibility path still requires explicit user authority. ## Contributing diff --git a/README.md b/README.md index e3761e6..1bd3f7d 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ 每个候选都要形成一份证明记录: +- 它位于哪个所有权边界、符号、文件,以及能够验证时的行号; - 它增加了什么维护负担; - 生产、测试、动态和外部消费者分别是谁; - 完整删除边界在哪里,包括共享文件内部的成员; @@ -69,6 +70,8 @@ git clone https://github.com/tt-a1i/simplify-codebase.git \ 安装后请新建一个任务,让 Skill 目录重新加载。其他支持 `SKILL.md` 的 Agent 环境可将本仓库放入各自的 Skill 目录。 +可交互 Cleanup Map 已内置在本 Skill 中,不需要另外安装 Archify。它直接内置精简后的 Architecture 渲染与桌面交互核心,再叠加清理专用编译和 Survey/Change 交互。renderer 需要 Node.js 18 或更高版本,不依赖额外 npm 包;交付的 HTML 不会请求外部字体。 + ## 使用 ### 审计整个仓库,不改文件 @@ -95,25 +98,43 @@ git clone https://github.com/tt-a1i/simplify-codebase.git \ 使用 $simplify-codebase 复核并整合这个 PR 中的简化建议。保留证据,不保留候选数量。 ``` +### 生成可视化伴随报告 + +```text +使用 $simplify-codebase 审计这个仓库。文字证明记录作为权威结果;如果多个已确认组件和消费者让问题位置难以理解,再额外交付带 Finding 深链接的 Cleanup Map。不要把图上可达关系称为运行时影响范围。 +``` + ## 输出是什么样的 只读审计会交付覆盖范围、排序后的证明记录、重要反例、未决问题和下一条所需证据。 修改任务会额外交付实际变更、分层验证结果、剩余风险、操作回执与可执行的撤销路径。一次小范围测试通过,不会被包装成完整的运行时或用户验收。 +当问题横跨多个组件、状态或消费者时,Skill 可以使用内置 renderer 额外交付经过校验的桌面端交互 HTML。Survey 按“定位、路径、删除边界、判断”组织,Change 按“变更前、删除边界、变更后、验证”组织。它是 proof record 的视觉伴随物,不替代消费者证明、Change 操作回执或撤销路径。拓扑尚未证明或图没有额外解释价值时,会正常退化为带精确文件定位的文字报告。 + ## 仓库结构 ```text . ├── SKILL.md # 主工作流与判断标准 +├── PRODUCT.md # 可视化产品定位与渐进披露原则 ├── agents/openai.yaml # Agent 展示与调用元数据 ├── references/ │ ├── investigation.md # 全库调查与候选发现 │ ├── boundaries-and-lifecycle.md │ ├── execution-and-recovery.md │ ├── decision-records.md -│ └── integrating-findings.md +│ ├── integrating-findings.md +│ └── visual-reporting.md # 可选视觉伴随层的真实性与交付契约 +├── visualization/ +│ ├── cleanup-map.schema.json # 清理专用语义契约 +│ ├── render-cleanup-map.mjs # Cleanup Map → Archify Architecture 编译与交付 +│ ├── archify-core/ # 内置的 Architecture renderer 与桌面 viewer 核心 +│ ├── cleanup-extension.* # Survey / Change 专用交互与视觉扩展 +│ ├── examples/ # Survey 与 Change 输入示例 +│ └── test/ # 契约、路径与产物测试 ├── docs/validation.md # 行为验证与质量证据 +├── docs/visual-report-example.md └── assets/hero.png # 原创 Hero 视觉 ``` @@ -121,6 +142,8 @@ git clone https://github.com/tt-a1i/simplify-codebase.git \ 这个版本经过 Change、Broad、Integration 和 Decision-record 场景验证,也在一个 973 文件的 Python + TypeScript 项目上完成过全库审计。测试方法与已知边界记录在 [docs/validation.md](./docs/validation.md)。 +视觉伴随层直接内置 Archify 的 Architecture renderer、Signal Flow 视觉系统和桌面 viewer 运行时,并在其上增加 Finding、Survey/Change 阶段、删除边界和按需证据抽屉。默认界面先用一两句话说清问题,再让源码、路径和决策证据随阶段展开;图始终占据主要视觉空间。其他通用图种、仓库 CLI、发布与图库流程没有搬入。来源、修改边界和 MIT 许可保留在 [`visualization/`](./visualization/);报告格式示例见 [docs/visual-report-example.md](./docs/visual-report-example.md)。 + Skill 不能替代产品决策。删除仍然可达的能力、已支持接口、持久化表示或兼容路径时,仍需由使用者明确授权。 ## 贡献 diff --git a/SKILL.md b/SKILL.md index 5e3139a..0acfc5d 100644 --- a/SKILL.md +++ b/SKILL.md @@ -45,7 +45,10 @@ Use repository-native search, compiler and linter output, dependency metadata, a For every in-scope lead that reaches consumer-map evidence or could retire a meaningful contract, record: ```text +Finding ID: a stable report-local identifier such as S1 or S2 Candidate: the exact contract, representation, or layer to remove or merge +Locus: the ownership boundary, symbols, and exact paths or lines when verified +Topology: confirmed node and relationship IDs, primary locus, related nodes, route, and cut set when useful; otherwise not applicable Burden: the concepts, synchronization, publication, or testing cost it creates Reachability: production, non-production, dynamic, external, and persisted consumers Rationale: why it exists and whether that reason remains current @@ -56,6 +59,8 @@ Proof: the smallest check that would expose an incorrect cut Net effect: maintenance concepts removed minus replacement or migration machinery added ``` +Keep Finding IDs stable within the run so the summary, proof record, source links, and any visual companion refer to the same candidate. Do not invent a line number, owner, relationship, or route to fill the location fields. Mark unknown facts as unresolved. + Prove cut boundaries below file granularity when the candidate shares an artifact with surviving consumers. Account for candidate-exclusive selectors, members, fields, keys, registry entries, generated fragments, and fixtures without disturbing the surviving owners. Keep or downgrade the candidate when a real consumer exists, dynamic reachability remains unresolved, a current decision still owns the design, the change merely relocates complexity, the result is outside scope or retires no meaningful obligation, or the available check cannot distinguish success from accidental breakage. @@ -74,8 +79,17 @@ If the user requests a simplification proposal, local cleanup annotation, or des If the user asks to combine findings from another branch, pull request, task, or agent run, read [integrating-findings.md](references/integrating-findings.md). Preserve evidence, not finding counts. +When the user requests a visual report, or when several confirmed components and relationships make a candidate's locus materially clearer, read [visual-reporting.md](references/visual-reporting.md). Use the bundled cleanup-map renderer rather than requiring a general diagram Skill. Treat the map as an optional companion to the proof records, not as evidence by itself. Do not write Survey artifacts into the target repository unless the user requested repository files. + ## Deliver the result -For a survey, report coverage, ranked proof records, rejected or unresolved high-value leads, and the next fact needed for each uncertainty. +For a survey, report coverage, ranked proof records, rejected or unresolved high-value leads, and the next fact needed for each uncertainty. When a visual companion was requested or justified, report its status and cleanup-map Finding deep links separately. + +Keep the handoff scan-friendly. Lead with the result, then include only the +proof records, decision-relevant uncertainty, visual links, and validation that +the user needs. Do not narrate the search process, repeat the same evidence +under several labels, append generic advice, or keep empty sections. Concision +does not remove required proof fields: write each field once, in the shortest +form that preserves its subject, evidence strength, consequence, and boundary. -For a change, complete the validation and operation receipt defined in [execution-and-recovery.md](references/execution-and-recovery.md). Report each validation layer separately; a narrow green check does not establish broader runtime, deployment, or user acceptance. +For a change, complete the validation and operation receipt defined in [execution-and-recovery.md](references/execution-and-recovery.md). A Before/Cut/After/Verify cleanup map may explain a structural change, but it never replaces the operation receipt. Report each validation layer separately; a narrow green check does not establish broader runtime, deployment, or user acceptance. diff --git a/docs/validation.md b/docs/validation.md index 79aba05..aec4280 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -30,6 +30,12 @@ This document records the behavioral validation completed before the initial ope - Expected behavior: prove the cut below file granularity. - Result: the focused audit found 14 candidate-exclusive CSS classes, identified removable members inside mixed selectors, retained the shared stylesheet and surviving component, and found two stale documentation references. +### Cleanup Map renderer + +- Fixture: one Survey and one Change cleanup-map document covering a confirmed dispatch path and an adapter cut. +- Expected behavior: reject guessed or dangling topology, unsafe links, and inconsistent Change snapshots; keep Finding IDs stable; trace only confirmed directed relationships; render distinct Survey and Change stages; progressively disclose stage-specific information without duplicate cards; keep first-paint and post-probe page geometry stable; and produce a portable standalone HTML artifact. +- Result: all 13 renderer tests passed on Node.js 18.20.8 and 26.7.0; both fixtures passed the dependency-free cleanup-map contract validator; and the checked-in Draft 2020-12 schema's authored surface is covered by a parity test. Both delivered artifacts passed all 7 structural checks. The suite also rejects overlong or duplicate unknowns, control-character URL obfuscation, unsafe URL schemes, incomplete or type-wrong cuts, route/cut semantics outside a Finding, retained cuts, dangling snapshot edges, and multi-Finding Change receipts. The vendored Archify clean-flow gate accepted node spacing, relationship-label placement, and the Change bypass route. Browser review passed at 1280×800 and 1440×900 in light and dark themes with no page-level overflow or console warnings/errors; repeated first-load, stage, evidence, focus, zoom, and rapid-switch sampling kept `scrollY`, document height, and the panel/workspace coordinates stable. Cut snapshot isolation, deep links, role labels, shortcut isolation, truth-boundary visibility, and small-text contrast were also checked directly. + ## Mechanical checks / 机械检查 - Skill structure and frontmatter validation; @@ -37,7 +43,9 @@ This document records the behavioral validation completed before the initial ope - internal Markdown link validation; - Markdown lint with line-length enforcement disabled for prose and long commands; - upstream-name and attribution scan; -- clean-worktree checks for read-only scenarios. +- clean-worktree checks for read-only scenarios; +- cleanup-map schema-parity, referential-integrity, Change-snapshot, link-scheme, shortest-path, script-injection, and offline portable-artifact tests; +- standalone artifact checks for the Archify runtime, one semantic SVG, cleanup workbench, passport, route probe, and truth boundary; ## Known limits / 已知边界 @@ -45,3 +53,6 @@ This document records the behavioral validation completed before the initial ope - Dynamic consumers outside the inspected repository can remain unknowable without external evidence. - Deployment, production health, and end-user acceptance remain separate gates from repository tests. - A user must still authorize product or compatibility changes. +- The bundled Cleanup Map is desktop-only. It is intentionally bounded to five visualized Findings, 18 nodes, and 32 confirmed relationships per artifact. +- The renderer's structural checks do not replace visual review, repository evidence, runtime verification, or the canonical text proof records. +- Cleanup Map vendors Archify's Architecture renderer and desktop viewer core. Other diagram renderers, the generic repository CLI, publishing, gallery, and multi-diagram validator are outside this distribution. diff --git a/docs/visual-report-example.md b/docs/visual-report-example.md new file mode 100644 index 0000000..ee47c74 --- /dev/null +++ b/docs/visual-report-example.md @@ -0,0 +1,82 @@ +# Cleanup Map handoff example + +This is a format example, not a finding about a real repository. Replace every +placeholder with verified facts and absolute artifact paths from the current +run. The written proof record remains authoritative. + +## Survey + +```text +Coverage: entrypoint, coordinator, worker lifecycle, state publication, tests +Blind spot: external plugin consumers were not available + +Finding index +S1 · ranked · high confidence · /src/job-state.ts: + Duplicate readiness representation across one ownership boundary + Locate: survey-cleanup-map.html#finding=S1&stage=locate + Trace: survey-cleanup-map.html#finding=S1&stage=trace + Cut: survey-cleanup-map.html#finding=S1&stage=cut + +Finding ID: S1 +Candidate: merge the legacy readiness flag into the canonical lifecycle state +Locus: JobRuntime; /src/job-state.ts:; /src/publisher.ts: +Topology: primary legacyReady; related worker, lifecycleState, legacyReady, publisher; confirmed route worker to publisher; cut legacyReady and its synchronization relationships +Burden: two representations must be synchronized before publication +Reachability: runtime publisher reads both; tests cover both; external plugins unresolved +Rationale: the flag predates the canonical lifecycle state; no current decision record owns both +Cut: declaration, write path, publication branch, dedicated fixture, and stale documentation +Consequence: the legacy readiness representation is no longer available +Confidence / risk: high repository confidence; external plugin uncertainty prevents Change authority +Proof: a publication test that fails if completion is emitted before canonical terminal state +Net effect: one state representation and one synchronization branch retired; no replacement layer + +Visual companion: delivered +Reason: S1 spans four confirmed runtime concepts and is easier to locate as a cleanup decision map +Specification: /survey.cleanup-map.json +Artifact: /survey-cleanup-map.html +Finding links: S1 -> #finding=S1&stage=trace and #finding=S1&stage=cut +Evidence level: authored confirmed relationships +Validation: PASS cleanup-map contract validation; PASS all seven artifact checks +Visual review: passed at 1280x800 and 1440x900 after inspecting Locate, Trace, Cut, node passport, and route probe +Limitations: source links omitted because the example is not revision-pinned +``` + +Generate the artifact with the bundled renderer: + +```bash +node /visualization/render-cleanup-map.mjs deliver \ + /survey.cleanup-map.json \ + /survey-cleanup-map.html +``` + +Lower-ranked, rejected, and unresolved Findings still belong in the text report +even when the map visualizes only the top five. + +## Change + +```text +Scope: JobRuntime lifecycle ownership boundary +Baseline: +Retired obligation: legacy readiness representation and synchronization branch +Artifacts: +Realized net effect: one state, one branch, one fixture, and one documentation obligation removed +Behavior: completion remains gated by the canonical terminal state +Verification: +Residual risk: +Retained candidates: +Undo: + +Visual companion: delivered +Reason: the cut removes one state owner and reroutes publication to the surviving source +Specification: /change.cleanup-map.json +Artifact: /change-cleanup-map.html +Finding links: S1 -> #finding=S1&stage=before, #finding=S1&stage=cut, #finding=S1&stage=after, and #finding=S1&stage=verify +Evidence level: authored confirmed relationships +Validation: PASS cleanup-map contract validation; PASS all seven artifact checks +Visual review: passed at 1280x800 and 1440x900 after comparing Before, Cut, After, and Verify +Limitations: the uncommitted After snapshot is not revision-pinned; the map does not prove runtime correctness +``` + +If an implementation changes only local code inside one stable component, use +`Visual companion: skipped` and explain that no meaningful relationship context +changed. The Change operation receipt remains mandatory. diff --git a/references/visual-reporting.md b/references/visual-reporting.md new file mode 100644 index 0000000..e940724 --- /dev/null +++ b/references/visual-reporting.md @@ -0,0 +1,228 @@ +# Cleanup Map Companion + +Purpose: make a proved simplification candidate easier to locate, trace, and +judge without turning graph reachability into deletion authority. The proof +records remain canonical. The bundled cleanup-map compiler targets a vendored +Archify Architecture renderer and desktop viewer. The interaction remains +specialized for cleanup; it is not a general diagram-authoring workflow. + +## Preserve the authority boundary + +- `simplify-codebase` owns discovery, consumer classification, contract proof, + ranking, implementation, and validation. +- Render only nodes and directed relationships already supported by that + evidence. Every rendered relationship must be classified `confirmed`. +- Put guessed consumers, unresolved dynamic wiring, missing external evidence, + confidence, and risk in the Finding record. Never draw a guessed edge. +- Authored reachability is not measured runtime impact, blast radius, + causality, or proof that a cut is safe. +- A visual artifact does not broaden Change authority or authorize commits, + publication, deployment, migration, or durable-data changes. +- In Survey mode, keep the target worktree read-only. Write the JSON and HTML + to an agent artifact directory or a user-selected location outside it. + +The text report is complete without a map. The map is incomplete without the +text report. + +## Decide whether a cleanup map helps + +Create one when the user asks or when a Finding spans several confirmed owners, +consumers, states, stores, or boundaries. Good cases include a relay layer, +split truth, parallel lifecycle, duplicate representation, request/event path, +or a Change that removes or reroutes ownership. + +Skip it when one source range already locates the issue, topology remains +unresolved, the candidate has no meaningful relationship context, or the map +would imply more confidence than the proof supports. + +Visualize at most five ranked Findings and 18 primary nodes in one artifact. +Keep every lower-ranked Finding in the text report. Split a Broad Survey by +ownership boundary instead of building a repository hairball. + +## Author the cleanup-map contract + +Use [`../visualization/cleanup-map.schema.json`](../visualization/cleanup-map.schema.json) +and the examples under [`../visualization/examples/`](../visualization/examples/). +Do not generate generic Archify JSON and translate it afterward. + +Map repository evidence into these concepts: + +```text +meta: Survey or Change, Focused or Broad, title, repository, revision +nodes: real entrypoints, owners, candidates, consumers, state, stores, boundaries, or external systems +relationships: confirmed directed call, data, registration, publication, lifecycle, or dependency edges +findings: stable Finding ID, disposition, confidence, primary locus, related nodes, optional route, cut set, proof, consequence, unknowns +change: one Finding ID plus authored Before and After snapshots and a concise verification receipt +``` + +Keep visual copy within the contract limits enforced by the schema and +renderer. Titles name the concrete problem. The Finding summary states what is +redundant or conflicting and why that matters, in one or two sentences. Proof +names the check that can distinguish a safe cut from breakage. Consequence says +what behavior or compatibility disappears. Keep at most three unresolved items +that could change the decision; move background and investigation history to +the canonical text report. + +Do not write process narration such as "we analyzed" or "the following diagram +shows." Do not repeat the route in prose when the graph already shows it. Avoid +value claims, generic recommendations, and conclusions that are not supported +by the Finding evidence. When `locale` is `zh-CN`, write native Chinese while +preserving IDs, symbols, paths, commands, and evidence strength exactly. + +Keep Finding IDs in the Finding navigator rather than inventing problem-shaped +runtime components. A candidate node must still represent a real symbol, +contract, state, representation, or layer. Use repository-relative source paths +and verified lines; omit unknown lines. Visualize only ranked candidates in +Survey mode and one changed Finding in Change mode; every visualized Finding +must name a non-empty cut. Use optional `locus.href` or `report_url` only for +HTTPS URLs or portable relative/hash links. + +The route endpoints must have a confirmed directed path, and the selected +Finding route must pass through its primary node. In Change mode, the single +Finding's cut must appear in Before and be absent from After; every snapshot +relationship must retain both endpoint nodes. The combined document may contain +both retired and new relationships, so use the Finding route to locate the old +candidate and use Before/After snapshots for the whole system path. + +## Use the cleanup-specific interaction + +Survey is not a slideshow. Each selected Finding follows four investigation +stages: + +1. **Locate** isolates the primary ownership locus. +2. **Trace** follows the shortest confirmed authored route through it. +3. **Cut** marks the exact candidate nodes and relationships proposed for + retirement. +4. **Decide** restores the related context and exposes the decisive proof, + consequence, confidence, risk, and unresolved facts. + +Change uses a different four-stage receipt: + +1. **Before** shows the authored pre-change snapshot. +2. **Cut** marks what the implementation retired. +3. **After** shows the surviving authored snapshot with stable IDs. +4. **Verify** keeps the After topology visible while exposing the separate + verification receipt. + +Node selection opens a semantic passport containing the verified locus, +confirmed incoming/outgoing relationships, and related Findings. The route +probe may compare any two nodes, but it traverses only authored confirmed +relationships. A selected node must remain visibly selected even when the +cleanup stage de-emphasizes surrounding context. Preserve Archify's native +`focus`, `relation`, `route`, and reachability hashes; the cleanup hash handler +owns only `finding`, `stage`, and legacy Finding views. + +Finding and stage transitions must clear any open semantic passport and must +never synthesize a node selection. Stage emphasis and camera framing are visual +guidance only. Open the passport only after an explicit node or relationship +interaction, or when restoring an explicit native `focus` or `relation` link. + +Treat the diagram camera as one shared state. Direct node selection frames the +selected node with its immediate neighbors at a restrained automatic scale; +manual zoom is the only path to close inspection. Locate frames the primary locus, Trace frames the +authored route, Cut frames the exact cut set, and Decide or Verify fits the +related decision context. Manual `+`, `-`, and reset controls must interrupt an +in-flight semantic camera before calculating the next scale, and the camera +indicator must distinguish automatic framing from manual zoom. Refit after the +evidence rail changes the canvas width instead of preserving a stale transform. + +Use progressive disclosure instead of repeating the proof record inside the +artifact. Keep the active Finding title and concise summary visible once as the +reading entry point, followed by disposition, confidence/risk, four stage +controls, a one-sentence stage guide, and the graph. The summary explains what +the user is looking at; the stage guide explains how to read the current visual +state. Neither should restate graph topology or detailed proof. + +Let Locate reveal the source passport, Trace reveal the route probe, and Cut +communicate mainly through visual state. Preserve non-focused semantics as +readable gray context rather than making them disappear. Reveal proof, +consequence, and unresolved facts automatically at Decide or Verify, with one +visible Evidence control for earlier access. On desktop, show that detailed +evidence beside the graph so the decision text and visual context can be read +together; never let it push the graph below the fold. Do not render the same +Finding summary again as a long page subtitle or generic information card. + +Load the report in a stable visual state. Do not enable Archify's ambient trace +animation for Cleanup Map: the sequential node pulse and canvas scan can read +as flicker and do not carry cleanup evidence. A stage change may use one short +opacity transition after initial state is settled; never animate SVG filters. + +Deep links are stable within the artifact: + +```text +#finding=S1&stage=trace +#finding=S1&stage=cut +#focus=runtimeOwner +#route=entrypoint~publisher +``` + +The canonical text index should link each visualized Finding to its most useful +stage, normally `trace` for location questions and `cut` for deletion-boundary +review. + +## Render and validate + +Resolve the installed Skill directory rather than assuming a sibling checkout. +The bundled renderer needs Node.js 18 or newer and has no npm package +dependency. It does not need a separate Archify installation because the +Architecture rendering and viewer core live under `visualization/archify-core/`. + +```bash +node /visualization/render-cleanup-map.mjs validate +node /visualization/render-cleanup-map.mjs deliver +node /visualization/render-cleanup-map.mjs check +``` + +`deliver` performs input and artifact checks. Then open the HTML and visually +inspect it at the desktop acceptance sizes 1280×800 and 1440×900. Check: + +- the two-level analysis header, cleanup stage rail, and native Archify canvas + remain simultaneously readable without obscuring toolbar or diagram controls; +- each linked Finding and stage opens correctly; +- clicking a node produces a persistent selected state and keeps its semantic + passport open, including after reloading a native focus deep link; +- repeated node clicks, stage changes, manual zoom, reset, and evidence-rail + changes produce a consistent camera result and truthful mode/percentage; +- the primary node, confirmed route, and cut set match the proof record; +- Before and After hide the correct retired or newly authored semantics; +- Finding titles and summaries wrap without hard truncation, and long labels do + not hide IDs, edge direction, source loci, proof, or unknowns; +- contextual nodes and relationships remain legible as secondary gray content; +- horizontal graph scrolling, when needed, preserves readable text. + +Do not report visual review as passed from renderer output alone. + +## Relationship to Archify + +The bundled implementation directly vendors Archify's Architecture renderer, +Signal Flow visual system, layout gates, localization, and standalone desktop +viewer runtime. The cleanup surface keeps the native semantic passport, route +probe, finder, radar, pan/zoom, theme/motion controls, and export surface instead +of recreating their appearance. It hides Archify's generic legend, semantic +lens, style switcher, presentation/guide controls, guided story, and renderer +cards because they duplicate or mislabel cleanup concepts. + +The cleanup layer owns a separate data contract and compiler. It adds stable +Finding navigation, the two four-stage investigation models, cut/snapshot +semantics, local repository loci, and an on-demand decision drawer. Archify's workflow, +sequence, dataflow, and lifecycle renderers; generic repository CLI; publishing +and gallery flows; and multi-diagram generated validator are intentionally +excluded. Attribution, adaptation notes, and the MIT license are preserved +under [`../visualization/`](../visualization/). + +## Deliver a separate visual receipt + +```text +Visual companion: delivered | skipped | failed +Reason: why the map added value, was omitted, or failed +Specification: absolute path when delivered +Artifact: absolute path when delivered +Finding links: Finding ID to finding/stage, node, or route deep link +Evidence level: authored confirmed relationships; revision-pinned only when separately established +Validation: exact cleanup-map validate/deliver/check results +Visual review: passed at 1280×800 and 1440×900 | skipped with reason | failed with defect +Limitations: unresolved topology, omitted findings, unsupported source links, dirty worktree, or horizontal overflow +``` + +A failed map does not invalidate a complete text audit, but its failure must +remain visible in the handoff. diff --git a/visualization/LICENSE.archify b/visualization/LICENSE.archify new file mode 100644 index 0000000..4c27b71 --- /dev/null +++ b/visualization/LICENSE.archify @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 tt-a1i (Archify) +Copyright (c) 2025 Cocoon AI (original "architecture-diagram-generator") + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/visualization/NOTICE.md b/visualization/NOTICE.md new file mode 100644 index 0000000..5914924 --- /dev/null +++ b/visualization/NOTICE.md @@ -0,0 +1,19 @@ +# Attribution and vendoring notes + +`archify-core/` contains source copied from +[Archify](https://github.com/Dreamaker-TA/archify), including its Architecture +renderer, visual template, desktop interaction runtime, geometry and layout +checks, localization, and supporting shared modules. + +The copied visual core is used under Archify's MIT license. Copyright and +license text are preserved in [`LICENSE.archify`](LICENSE.archify) and +[`archify-core/LICENSE`](archify-core/LICENSE). + +Simplify Codebase adds a separate cleanup contract, Architecture compiler, and +Survey/Change extension. The upstream generated multi-diagram validator is not +vendored; `archify-core/renderers/shared/validator.mjs` is an +Architecture-only adapter for this distribution. + +Archify's other diagram renderers, generic CLI workflow, publishing, gallery, +and example corpus are intentionally excluded because they are not needed for +the cleanup decision map. diff --git a/visualization/README.md b/visualization/README.md new file mode 100644 index 0000000..517a152 --- /dev/null +++ b/visualization/README.md @@ -0,0 +1,102 @@ +# Cleanup Map Visualizer + +This directory contains the desktop-only visualization system for +`simplify-codebase`. It vendors Archify's Architecture renderer and standalone +viewer core, then compiles the cleanup-specific contract into that renderer. +Archify does not need to be installed separately, and there is no npm package +dependency. + +## Architecture + +The delivery path has three layers: + +1. `cleanup-map.schema.json` describes proved cleanup semantics: Findings, + confirmed relationships, source loci, cut sets, and Change snapshots. +2. `render-cleanup-map.mjs` validates that contract and compiles it to the + vendored Archify Architecture intermediate representation. +3. `cleanup-extension.css` and `cleanup-extension.js` add cleanup-specific + Survey and Change stages without replacing Archify's visual system. + +`archify-core/` contains the trimmed Architecture renderer, layout and geometry +checks, localization, semantic viewer runtime, and viewer template. The upstream +multi-schema generated validator was replaced by a small Architecture-only +adapter because the other diagram types are not distributed here. + +The result keeps Archify's Signal Flow visual preset, pan and zoom, semantic +passport, route probe, finder, radar, theme controls, and standalone +export runtime. Cleanup Map adds: + +- Finding-oriented navigation rather than general architecture chapters; +- a compact analysis header that states what was found before the user reads + the graph; +- Survey stages: `Locate → Trace → Cut → Decide`; +- Change stages: `Before → Cut → After → Verify`; +- a stage-aware reading guide beside the four controls; +- an on-demand decision drawer for proof, consequence, and uncertainty; +- repository loci in the semantic passport; +- a visible reminder that confirmed reachability is not runtime impact or + deletion safety; +- strict rejection of guessed graph relationships. + +The default surface contains the active Finding's concise analysis summary, its +decision state, the four cleanup stages, a one-sentence reading guide, and the +graph. The graph remains the primary visual surface. Progressive disclosure is +stage-driven: Locate opens the source passport, Trace opens the native route +probe, Cut carries the deletion boundary visually while preserving readable +gray context, and Decide or Verify reveals a detailed evidence rail beside the +graph. The evidence rail never pushes the primary canvas below it. Generic +surfaces that duplicate or mislabel this workflow are not compiled into the +viewer. + +Cleanup Map compiles no ambient trace or sequential node animation. The report +loads into a stable graph; explicit stage changes retain a single 180ms opacity +transition, and SVG filters are not interpolated. + +Only desktop layouts are supported. Acceptance sizes are 1280×800 and +1440×900. + +## Commands + +Node.js 18 or newer is sufficient. + +```bash +node visualization/render-cleanup-map.mjs validate visualization/examples/survey.cleanup-map.json +node visualization/render-cleanup-map.mjs render visualization/examples/survey.cleanup-map.json /tmp/survey-cleanup-map.html +node visualization/render-cleanup-map.mjs check /tmp/survey-cleanup-map.html +``` + +`deliver` performs render and artifact checks in one command: + +```bash +node visualization/render-cleanup-map.mjs deliver input.cleanup-map.json output.html +``` + +The renderer performs referential and mode-specific checks that JSON Schema +alone cannot express. The vendored Archify renderer then applies its own layout, +label-overlap, edge-obstacle, and clean-flow gates. + +## Interaction contract + +Cleanup deep links use hash parameters and remain meaningful when the HTML is +moved: + +- `#finding=S1&stage=trace` opens one Finding at one investigation stage; +- `#focus=runtime-owner` opens Archify's semantic passport; +- `#route=entrypoint~publisher` opens Archify's shortest directed route probe. + +The route is authored reachability, not measured runtime impact. Missing or +uncertain relationships must stay out of the graph and appear under the +Finding's unresolved facts. + +## Deliberate scope cuts + +The vendored core does not include Archify's workflow, sequence, dataflow, or +lifecycle renderers; repository-evidence and output-path authoring workflows; +remote brand capture or its brand catalog; publishing and gallery flows; example +catalog; or multi-diagram generated validator. Those are unrelated to locating +and judging a code-cleanup cut. Generated artifacts also make no external font +request, so they remain usable offline. + +See [`NOTICE.md`](NOTICE.md), [`LICENSE.archify`](LICENSE.archify), the license +retained inside [`archify-core/`](archify-core/), and the pinned upstream +provenance in [`archify-core/UPSTREAM.md`](archify-core/UPSTREAM.md). diff --git a/visualization/archify-core/LICENSE b/visualization/archify-core/LICENSE new file mode 100644 index 0000000..4c27b71 --- /dev/null +++ b/visualization/archify-core/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 tt-a1i (Archify) +Copyright (c) 2025 Cocoon AI (original "architecture-diagram-generator") + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/visualization/archify-core/UPSTREAM.md b/visualization/archify-core/UPSTREAM.md new file mode 100644 index 0000000..b05bf7e --- /dev/null +++ b/visualization/archify-core/UPSTREAM.md @@ -0,0 +1,25 @@ +# Upstream provenance + +- Project: Archify +- Repository: +- Revision: `7fe139ebe2e532941eb4c315057294348e88a2c0` +- Package version: `2.16.0-dev.0` +- Source subtree: `archify/` + +The following upstream areas supplied this trimmed core: + +- `renderers/architecture/`; +- the shared renderer modules required by Architecture; +- `LICENSE`. + +The template, Architecture renderer, `renderers/shared/cli.mjs`, and the viewer +i18n catalog are +intentionally adapted for Cleanup Map: they remove brand capture, repository +evidence, and generic output-path workflows, and apply the cleanup camera/focus +behavior. The renderer also keeps visual palette types separate from Cleanup +role labels. `renderers/shared/validator.mjs` is replaced with a dependency-free +Architecture-only adapter. Cleanup Map validation happens before the Architecture +compiler runs. + +Use the revision above as the comparison base when refreshing this core. Review +the retained cleanup adaptations with template/runtime changes as one unit. diff --git a/visualization/archify-core/assets/template.html b/visualization/archify-core/assets/template.html new file mode 100644 index 0000000..a028579 --- /dev/null +++ b/visualization/archify-core/assets/template.html @@ -0,0 +1,8227 @@ + + + + + + + [PROJECT NAME] Architecture Diagram + + + + + + + +
+ +
+
+
+

[PROJECT NAME] Architecture

+
+

[Subtitle description]

+
+ + + +
+ + + +

+ + + + + + +
+ + +
+ + + + diff --git a/visualization/archify-core/renderers/architecture/grid.mjs b/visualization/archify-core/renderers/architecture/grid.mjs new file mode 100644 index 0000000..807a1b8 --- /dev/null +++ b/visualization/archify-core/renderers/architecture/grid.mjs @@ -0,0 +1,62 @@ +/** Grid placement for architecture IR (#8). Not auto-layout — fixed cell math only. */ + +export const DEFAULT_GRID = { + mode: 'grid', + origin: [40, 80], + cols: 4, + gapX: 30, + gapY: 40, + cellW: 130, + cellH: 64, +}; + +export function gridLayout(arch) { + const raw = arch.layout; + if (!raw || raw.mode !== 'grid') return null; + return { ...DEFAULT_GRID, ...raw }; +} + +export function resolveComponentPos(component, grid) { + if (Array.isArray(component.pos) && component.pos.length === 2) { + return component.pos; + } + if (!grid) return [NaN, NaN]; + if (!Number.isInteger(component.row) || !Number.isInteger(component.col)) { + return [NaN, NaN]; + } + const [ox, oy] = grid.origin; + const stepX = grid.cellW + grid.gapX; + const stepY = grid.cellH + grid.gapY; + return [ox + component.col * stepX, oy + component.row * stepY]; +} + +export function validateGridPlacement(arch, grid, problems) { + if (!grid) return; + if (arch.layout !== undefined && arch.layout.mode !== 'grid') { + problems.push('layout.mode must be "grid" when layout is set (free placement omits layout entirely).'); + return; + } + const seen = new Map(); + for (const c of arch.components ?? []) { + const hasPos = Array.isArray(c.pos) && c.pos.length === 2; + const hasCell = Number.isInteger(c.row) && Number.isInteger(c.col); + if (hasPos) continue; // pos wins; row/col are optional hints only + if (!hasPos && !hasCell) { + problems.push(`Component "${c.id}" needs pos [x,y] or grid row/col when layout.mode is "grid".`); + continue; + } + if (c.row < 0 || c.col < 0) { + problems.push(`Component "${c.id}" row/col must be non-negative integers.`); + continue; + } + if (c.col >= grid.cols) { + problems.push(`Component "${c.id}" col ${c.col} exceeds layout.cols ${grid.cols} (valid: 0..${grid.cols - 1}).`); + } + const key = `${c.row},${c.col}`; + if (seen.has(key)) { + problems.push(`Components "${seen.get(key)}" and "${c.id}" share grid cell row ${c.row} col ${c.col}.`); + } else { + seen.set(key, c.id); + } + } +} diff --git a/visualization/archify-core/renderers/architecture/render-architecture.mjs b/visualization/archify-core/renderers/architecture/render-architecture.mjs new file mode 100644 index 0000000..2c08a5f --- /dev/null +++ b/visualization/archify-core/renderers/architecture/render-architecture.mjs @@ -0,0 +1,1009 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs'; +import { focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagram, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs'; +import { componentBox, boundaryBox, connectionPath } from '../shared/layout-report.mjs'; +import { throwDiagnosticProblems } from '../shared/diagnostics.mjs'; +import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs'; +import { minimumReadableSourceTextPx } from '../shared/desktop-readability.mjs'; +import { translateMessage as i18nText } from '../shared/i18n.mjs'; +import { gridLayout, resolveComponentPos, validateGridPlacement } from './grid.mjs'; +import { + asArray, + isFinitePoint, + rectsOverlap, + segmentIntersectsRect, + cleanEndpointSideProblems, + cleanFlowProblems, + cleanCrossingProblems, + cleanAmbiguousCorridorProblems, + cleanBorderRunProblems, + cleanRouteRhythmProblems, + cleanLabelRouteClearanceProblems, + suggestLabelObstacleFix, + suggestComponentSeparation, + anchor, + automaticPortSpread, + automaticPortRhythmBridge, + defaultFromSide, + defaultToSide, + chosenSide, + routeHonorsEndpointSides, + normalizeRoutePoints, + polylinePath, + routePointsValue, + roundedPath, + labelPoint, + componentFill, + componentText, + arrowClassMap, + variantAccent, +} from '../shared/geometry.mjs'; + +const componentTextFit = { + sublabelPreferred: 9, + sublabelMinimum: 6, + tagPreferred: 7, + tagMinimum: 6, +}; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const layoutJsonMode = process.argv.includes('--layout-json'); +const cliArgs = process.argv.filter((arg) => arg !== '--layout-json'); +const { diagram: arch, template, outPath } = loadDiagram({ + rendererDir: __dirname, + diagramType: 'architecture', + defaultExample: 'web-app.architecture.json', + argv: cliArgs, +}); + +const grid = gridLayout(arch); + +const layout = { + defaultW: 120, + defaultH: 60, + margin: 40, + // Boundary padding — the 30/50 rule that was a hand-arithmetic footgun + // (CHANGELOG v2.2.1): 30px on top/left/right, plus 20px extra at the bottom. + boundaryPad: 30, + boundaryExtraBottom: 20, + boundaryLabelBaseline: 18, + boundaryLabelClearance: 4, + boundaryLabelFontPreferred: 9, + boundaryLabelFontMinimum: 6, + boundaryLabelMaskHeight: 16, + boundaryLabelRailGap: 2, + boundaryLabelFrameInset: 4, +}; + +// ---- Measure components from free coordinates -------------------------------- +function measureComponent(c) { + const [x, y] = resolveComponentPos(c, grid); + const [w, h] = Array.isArray(c.size) ? c.size : [layout.defaultW, layout.defaultH]; + return { ...c, x, y, width: w, height: h, cx: x + w / 2, cy: y + h / 2 }; +} + +const components = new Map(asArray(arch.components).map((c) => [c.id, measureComponent(c)])); +const enforcesBoundaryTitleComposition = Boolean(arch.meta?.quality_profile); +// ---- Boundaries computed from the `wraps` id list --------------------------- +function boundaryRect(boundary) { + const members = asArray(boundary.wraps).map((id) => components.get(id)).filter(Boolean); + if (!members.length) return null; + const minX = Math.min(...members.map((m) => m.x)); + const minY = Math.min(...members.map((m) => m.y)); + const maxX = Math.max(...members.map((m) => m.x + m.width)); + const maxY = Math.max(...members.map((m) => m.y + m.height)); + const pad = boundary.pad ?? layout.boundaryPad; + const topPad = Math.max( + pad, + layout.boundaryLabelBaseline + layout.boundaryLabelClearance, + ); + return { + ...boundary, + x: minX - pad, + y: minY - topPad, + width: maxX - minX + pad * 2, + height: maxY - minY + topPad + layout.boundaryExtraBottom, + memberTop: minY, + }; +} + +function rectContains(outer, inner) { + const epsilon = 1e-9; + return outer.x <= inner.x + epsilon + && outer.y <= inner.y + epsilon + && outer.x + outer.width + epsilon >= inner.x + inner.width + && outer.y + outer.height + epsilon >= inner.y + inner.height; +} + +function boundaryLabelWidth(label, fontSize) { + return Math.max(30, textUnits(label) * fontSize * 0.6 + 10); +} + +function autoViewBoxFor(candidateBoundaries) { + const maxX = Math.max( + 0, + ...[...components.values()].map((component) => component.x + component.width), + ...candidateBoundaries.map((boundary) => boundary.x + boundary.width), + ); + const maxY = Math.max( + 0, + ...[...components.values()].map((component) => component.y + component.height), + ...candidateBoundaries.map((boundary) => boundary.y + boundary.height), + ); + return [ + Math.ceil(maxX + layout.margin), + Math.ceil(maxY + layout.margin), + ]; +} + +function resolvedViewBoxWidth(candidateBoundaries) { + if (Array.isArray(arch.meta?.viewBox) && Number.isFinite(arch.meta.viewBox[0])) { + return arch.meta.viewBox[0]; + } + return autoViewBoxFor(candidateBoundaries)[0]; +} + +function expandBoundaryForReadableTitle(boundary, minimumFontSize) { + if (!enforcesBoundaryTitleComposition) return boundary; + const requiredWidth = boundaryLabelWidth(boundary.label, minimumFontSize) + + layout.boundaryLabelFrameInset * 2; + const extra = Math.max(0, requiredWidth - boundary.width); + if (!extra) return boundary; + return { + ...boundary, + x: boundary.x - extra / 2, + width: boundary.width + extra, + }; +} + +function measureBoundaryTitle(boundary, minimumFontSize) { + const availableWidth = Math.max(0, boundary.width - layout.boundaryLabelFrameInset * 2); + const units = textUnits(boundary.label); + const fitted = units > 0 + ? (availableWidth - 10) / (units * 0.6) + : layout.boundaryLabelFontPreferred; + const preferredFontSize = Math.max(layout.boundaryLabelFontPreferred, minimumFontSize); + const fontSize = Math.max( + minimumFontSize, + Math.min(preferredFontSize, fitted), + ); + const desiredWidth = boundaryLabelWidth(boundary.label, fontSize); + const height = Math.max(layout.boundaryLabelMaskHeight, Math.ceil(fontSize + 7)); + return { + x: boundary.x + layout.boundaryLabelFrameInset, + y: boundary.memberTop + - layout.boundaryLabelClearance + - height, + width: Math.min(availableWidth, desiredWidth), + height, + fontSize, + minimumFontSize, + baselineOffset: fontSize + 4, + availableWidth, + minimumWidth: boundaryLabelWidth(boundary.label, minimumFontSize), + }; +} + +function horizontalOverlap(left, right) { + return left.x < right.x + right.width && left.x + left.width > right.x; +} + +function layoutBoundaryTitles(rawBoundaries, minimumFontSize) { + const placedTitles = []; + const measured = new Map(); + const ordered = rawBoundaries + .map((boundary, index) => ({ boundary, index })) + .sort((left, right) => { + const areaDelta = left.boundary.width * left.boundary.height + - right.boundary.width * right.boundary.height; + return areaDelta || left.index - right.index; + }); + + for (const entry of ordered) { + const { index } = entry; + const boundary = expandBoundaryForReadableTitle(entry.boundary, minimumFontSize); + const title = measureBoundaryTitle(boundary, minimumFontSize); + let guard = 0; + while (guard < rawBoundaries.length + components.size + 1) { + guard += 1; + const blockers = [ + ...placedTitles, + ...components.values(), + ].filter((candidate) => horizontalOverlap(title, candidate) && rectsOverlap(title, candidate)); + if (!blockers.length) break; + title.y = Math.min( + ...blockers.map((blocker) => blocker.y - layout.boundaryLabelRailGap - title.height), + ); + } + placedTitles.push(title); + measured.set(index, { boundary, title }); + } + + return rawBoundaries.map((_boundary, index) => { + const { boundary, title } = measured.get(index); + const bottom = boundary.y + boundary.height; + // Profile-less schema-v1 inputs keep their legacy boundary geometry. A + // quality profile opts into the stricter title-composition contract and + // may expand the frame to contain an adapted title rail. + const y = enforcesBoundaryTitleComposition + ? Math.min(boundary.y, title.y - layout.boundaryLabelFrameInset) + : boundary.y; + return { + ...boundary, + y, + height: bottom - y, + title, + }; + }); +} + +const rawBoundaries = asArray(arch.boundaries).map(boundaryRect).filter(Boolean); +function resolveBoundaryTitles() { + if (!enforcesBoundaryTitleComposition || rawBoundaries.length === 0) { + return { + boundaries: layoutBoundaryTitles(rawBoundaries, layout.boundaryLabelFontMinimum), + readabilityProblem: null, + }; + } + + const maximumIterations = 32; + let candidateBoundaries = rawBoundaries; + for (let iteration = 0; iteration < maximumIterations; iteration += 1) { + const budgetViewBoxWidth = resolvedViewBoxWidth(candidateBoundaries); + const minimumFontSize = Math.max( + layout.boundaryLabelFontMinimum, + minimumReadableSourceTextPx(budgetViewBoxWidth) + 1e-6, + ); + const nextBoundaries = layoutBoundaryTitles(rawBoundaries, minimumFontSize); + const finalViewBoxWidth = resolvedViewBoxWidth(nextBoundaries); + const finalMinimumFontSize = Math.max( + layout.boundaryLabelFontMinimum, + minimumReadableSourceTextPx(finalViewBoxWidth), + ); + if (minimumFontSize >= finalMinimumFontSize) { + return { boundaries: nextBoundaries, readabilityProblem: null }; + } + candidateBoundaries = nextBoundaries; + } + + const finalViewBoxWidth = resolvedViewBoxWidth(candidateBoundaries); + return { + boundaries: candidateBoundaries, + readabilityProblem: `[composition/desktop-readability] Boundary title layout did not converge after ${maximumIterations} iterations for the final ${finalViewBoxWidth}px viewBox — shorten boundary labels, provide a wider authored viewBox, or move wrapped components closer to the left edge.`, + }; +} + +const resolvedBoundaryTitles = resolveBoundaryTitles(); +const boundaries = resolvedBoundaryTitles.boundaries; +const compositionFrames = boundaries.map((boundary, index) => ({ + ...boundary, + id: boundary.id || index, + kind: boundary.kind || 'boundary', + radius: boundary.kind === 'security-group' ? 8 : 12, +})); + +function componentContext(component) { + const scopes = boundaries + .filter((boundary) => asArray(boundary.wraps).includes(component.id)) + .sort((a, b) => (b.width * b.height) - (a.width * a.height)) + .map((boundary) => boundary.label); + return scopes.length ? scopes.join(' › ') : i18nText(arch.meta.locale, 'node.context.architecture'); +} + +// ---- Auto viewBox: fit all authored geometry -------------------------------- +const viewBox = arch.meta?.viewBox || autoViewBoxFor(boundaries); + +// ---- Validation: mechanical correctness, never layout taste ----------------- +function validateArchitecture() { + const problems = []; + if (resolvedBoundaryTitles.readabilityProblem) { + problems.push(resolvedBoundaryTitles.readabilityProblem); + } + const requiresNestedBoundaryMembership = arch.meta?.engineering_profile === 'deployment-ownership'; + if (arch.schema_version !== 1) problems.push('Architecture files must set "schema_version": 1.'); + if (arch.diagram_type !== 'architecture') problems.push('Architecture files must set "diagram_type": "architecture".'); + if (!arch.meta?.title) problems.push('Architecture files must include meta.title.'); + if (!Array.isArray(arch.components) || arch.components.length < 1) { + problems.push('Architecture diagrams need at least one component.'); + } + if (arch.connections !== undefined && !Array.isArray(arch.connections)) problems.push('Architecture "connections" must be an array.'); + if (arch.boundaries !== undefined && !Array.isArray(arch.boundaries)) problems.push('Architecture "boundaries" must be an array.'); + if (components.size !== asArray(arch.components).length) problems.push('Component ids must be unique.'); + if (grid) { + validateGridPlacement(arch, grid, problems); + } else { + for (const c of asArray(arch.components)) { + if (!Array.isArray(c.pos) || c.pos.length !== 2) { + problems.push(`Component "${c.id}" must include pos [x, y] when layout.mode is omitted (free placement).`); + } + } + } + + for (const c of components.values()) { + if (!isFinitePoint(c.x, c.y, c.width, c.height)) { + problems.push(`Component "${c.id}" has non-finite pos/size — pos and size must be [number, number].`); + continue; + } + if (c.width <= 0 || c.height <= 0) { + problems.push(`Component "${c.id}" has invalid size ${c.width}x${c.height} — width and height must be greater than 0.`); + continue; + } + if (c.x < 0 || c.y < 0 || c.x + c.width > viewBox[0] || c.y + c.height > viewBox[1]) { + problems.push(`Component "${c.id}" falls outside the viewBox ${viewBox[0]}x${viewBox[1]} — adjust pos/size or set a larger meta.viewBox.`); + } + const estLabelW = textUnits(c.label) * 6.6; + if (estLabelW > c.width + 8) { + problems.push(`Label "${c.label}" (~${Math.round(estLabelW)}px) is wider than component "${c.id}" (${c.width}px) — shorten the label or widen size.`); + } + // sublabel and tag render as single unwrapped elements; shrink-to-fit + // handles the ordinary case, this rejects what it cannot rescue. + const availableTextW = availableNodeTextWidth(c.width); + for (const [field, value, minimum] of [ + ['Sublabel', c.sublabel, componentTextFit.sublabelMinimum], + ['Tag', c.tag, componentTextFit.tagMinimum], + ]) { + if (!value) continue; + const minimumW = minimumNodeTextWidth(value, minimum); + if (minimumW > availableTextW) { + problems.push(`${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but component "${c.id}" provides ${availableTextW}px — shorten the ${field.toLowerCase()} or widen size.`); + } + } + } + + // Component overlap — the highest-traffic hand-placement failure mode. + const list = [...components.values()]; + for (let i = 0; i < list.length; i += 1) { + for (let j = i + 1; j < list.length; j += 1) { + if (rectsOverlap(list[i], list[j], 8)) { + problems.push(`Components "${list[i].id}" and "${list[j].id}" are less than 8px apart — move one or shrink its size.\n${suggestComponentSeparation(list[i], list[j], 8)}`); + } + } + } + + // Boundaries: every wrapped id must exist; the computed box must stay in view. + for (const boundary of asArray(arch.boundaries)) { + for (const id of asArray(boundary.wraps)) { + if (!components.has(id)) problems.push(`Boundary "${boundary.label}" wraps unknown component "${id}".`); + } + } + const viewBoxRect = { x: 0, y: 0, width: viewBox[0], height: viewBox[1] }; + for (const boundary of boundaries) { + if (!enforcesBoundaryTitleComposition) continue; + if (boundary.title.minimumWidth > boundary.title.availableWidth) { + problems.push( + `Boundary label "${boundary.label}" needs ~${Math.ceil(boundary.title.minimumWidth)}px to fit at the ` + + `${Number(boundary.title.minimumFontSize.toFixed(2))}px desktop-readable source minimum, but its frame provides ${Math.floor(boundary.title.availableWidth)}px — ` + + 'shorten the boundary label, increase pad, or widen the wrapped component layout.', + ); + } + if (!rectContains(boundary, boundary.title)) { + problems.push( + `Boundary label "${boundary.label}" extends outside its final frame — shorten the label or increase boundary pad.`, + ); + } + if (!rectContains(viewBoxRect, boundary.title)) { + problems.push( + `Boundary label "${boundary.label}" extends outside the viewBox — move wrapped components away from the canvas edge, shorten the label, or increase the viewBox.`, + ); + } + for (const component of components.values()) { + if (!rectsOverlap(boundary.title, component)) continue; + problems.push( + `Boundary label "${boundary.label}" overlaps component "${component.id}" — move the component, increase boundary title space, or shorten the label.`, + ); + } + } + for (let leftIndex = 0; leftIndex < boundaries.length; leftIndex += 1) { + const left = boundaries[leftIndex]; + const leftMembers = new Set(asArray(left.wraps)); + for (let rightIndex = leftIndex + 1; rightIndex < boundaries.length; rightIndex += 1) { + const right = boundaries[rightIndex]; + if (enforcesBoundaryTitleComposition && rectsOverlap(left.title, right.title)) { + problems.push( + `Boundary labels "${left.label}" and "${right.label}" overlap — shorten a label or increase boundary title space.`, + ); + } + // Ordinary architecture boundaries are sets, not an implied ownership + // tree: orthogonal scopes such as runtime and compliance may share some + // components while each contains others. The opt-in deployment profile + // does promise hierarchical region/private-scope membership, so only it + // receives the stricter membership-to-frame containment contract. + if (!requiresNestedBoundaryMembership) continue; + const rightMembers = new Set(asArray(right.wraps)); + const shared = [...leftMembers].filter((id) => rightMembers.has(id)); + const leftNested = [...leftMembers].every((id) => rightMembers.has(id)); + const rightNested = [...rightMembers].every((id) => leftMembers.has(id)); + if (shared.length && !leftNested && !rightNested) { + const leftOnly = [...leftMembers].filter((id) => !rightMembers.has(id)); + const rightOnly = [...rightMembers].filter((id) => !leftMembers.has(id)); + problems.push( + `Boundary "${left.label}" crosses boundary "${right.label}" because their memberships partially overlap ` + + `(shared: ${shared.map((id) => `"${id}"`).join(', ')}; ` + + `only in "${left.label}": ${leftOnly.map((id) => `"${id}"`).join(', ')}; ` + + `only in "${right.label}": ${rightOnly.map((id) => `"${id}"`).join(', ')}) — ` + + 'keep one boundary fully nested by removing outside members, or split the boundary.', + ); + continue; + } + + if (!rectsOverlap(left, right)) continue; + const leftContainsRight = rectContains(left, right); + const rightContainsLeft = rectContains(right, left); + if (!leftContainsRight && !rightContainsLeft) { + problems.push( + `Boundary "${left.label}" and boundary "${right.label}" final frames partially overlap — ` + + 'adjust wraps, pad, or component positions so the frames are disjoint or one fully contains the other.', + ); + continue; + } + + if (!shared.length) { + problems.push( + `Boundary "${left.label}" and boundary "${right.label}" final frames overlap even though their memberships are disjoint — ` + + 'adjust pad or component positions so the frames are disjoint, or make wraps express the intended nesting.', + ); + continue; + } + + const containmentMatchesMembership = (leftNested && rightContainsLeft) + || (rightNested && leftContainsRight); + if (!containmentMatchesMembership) { + problems.push( + `Boundary "${left.label}" and boundary "${right.label}" final frame containment contradicts their wraps membership — ` + + 'reduce the inner boundary pad, move its components, or correct wraps so geometry and nesting agree.', + ); + } + } + } + for (const b of boundaries) { + if (b.x < 0 || b.y < 0 || b.x + b.width > viewBox[0] || b.y + b.height > viewBox[1]) { + problems.push(`Boundary "${b.label}" extends outside the viewBox — its members sit too close to the canvas edge; add margin or enlarge meta.viewBox.`); + } + } + + for (const conn of asArray(arch.connections)) { + if (!components.has(conn.from)) problems.push(`Connection "${conn.label || conn.from}" references unknown source "${conn.from}".`); + if (!components.has(conn.to)) problems.push(`Connection "${conn.label || conn.to}" references unknown target "${conn.to}".`); + if (components.has(conn.from) && components.has(conn.to)) { + const routed = pathFor(conn); + const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]]; + const distance = Math.hypot(end[0] - start[0], end[1] - start[1]); + if (distance < 24) problems.push(`Connection "${conn.label || `${conn.from}->${conn.to}`}" is too short (${Math.round(distance)}px; minimum 24px) — place its components farther apart.`); + } + } + + problems.push(...cleanEndpointSideProblems({ + relations: arch.connections, + endpointIds: new Set(components.keys()), + pathFor, + diagramType: 'architecture', + relationCollection: 'connections', + fromSideFor: (conn) => connectionEndpointSide(conn, 'source'), + toSideFor: (conn) => connectionEndpointSide(conn, 'target'), + routeHint: 'keep automatic routing so the renderer can use a side-aware bridge, or set truthful fromSide/toSide with perpendicular via segments', + })); + problems.push(...cleanFlowProblems({ + relations: arch.connections, + endpointIds: new Set(components.keys()), + obstacles: components.values(), + pathFor, + diagramType: 'architecture', + relationCollection: 'connections', + obstacleKind: 'component', + profile: arch.meta?.quality_profile, + routeHint: 'adjust fromSide/toSide, set route/via, or move the component' + })); + problems.push(...cleanCrossingProblems({ + relations: arch.connections, + endpointIds: new Set(components.keys()), + pathFor, + diagramType: 'architecture', + relationCollection: 'connections', + profile: arch.meta?.quality_profile, + routeHint: 'adjust route/via or fromSide/toSide so the connections use separate corridors' + })); + problems.push(...cleanAmbiguousCorridorProblems({ + relations: arch.connections, + endpointIds: new Set(components.keys()), + pathFor, + diagramType: 'architecture', + relationCollection: 'connections', + profile: arch.meta?.quality_profile, + routeHint: 'adjust route/via or fromSide/toSide so unrelated connections do not visually merge' + })); + problems.push(...cleanBorderRunProblems({ + relations: arch.connections, + endpointIds: new Set(components.keys()), + frames: compositionFrames, + pathFor, + diagramType: 'architecture', + relationCollection: 'connections', + profile: arch.meta?.quality_profile, + routeHint: 'adjust route/via or fromSide/toSide so the connection crosses the boundary perpendicularly instead of following its border' + })); + problems.push(...cleanRouteRhythmProblems({ + relations: arch.connections, + endpointIds: new Set(components.keys()), + pathFor, + diagramType: 'architecture', + relationCollection: 'connections', + profile: arch.meta?.quality_profile, + routeHint: 'move route/via points into a wider corridor or move the component so every turn has room to read' + })); + + // Connection labels must not land on top of components. + const labelRects = []; + for (const [connectionIndex, conn] of asArray(arch.connections).entries()) { + if (!conn.label || !components.has(conn.from) || !components.has(conn.to)) continue; + const [lx, ly] = labelPoint(conn, pathFor(conn).points); + const w = Math.max(30, textUnits(conn.label) * 4.8 + 10); + labelRects.push({ relation: conn, relationIndex: connectionIndex, label: conn.label, x: lx - w / 2, y: ly - 10, width: w, height: 14, lx, ly }); + } + for (const rect of labelRects) { + for (const c of components.values()) { + if (rectsOverlap(rect, c, -2)) { + problems.push(`Label "${rect.label}" overlaps component "${c.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, c)}`); + } + } + if (enforcesBoundaryTitleComposition) { + for (const boundary of boundaries) { + if (!rectsOverlap(boundary.title, rect)) continue; + problems.push( + `Boundary label "${boundary.label}" overlaps connection label "${rect.label}" — move the boundary title rail by adjusting wrapped component positions, or move the connection label with labelAt/labelDx/labelDy/labelSegment.`, + ); + } + } + } + problems.push(...cleanLabelRouteClearanceProblems({ + relations: arch.connections, + labels: labelRects, + endpointIds: new Set(components.keys()), + pathFor, + diagramType: 'architecture', + relationCollection: 'connections', + profile: arch.meta?.quality_profile, + })); + + if (problems.length) { + throwDiagnosticProblems('Architecture layout validation failed', problems, { + subject: { diagramType: 'architecture' }, + }); + } +} + +function buildLayoutReport() { + const labels = []; + for (const conn of asArray(arch.connections)) { + if (!conn.label || !components.has(conn.from) || !components.has(conn.to)) continue; + const [lx, ly] = labelPoint(conn, pathFor(conn).points); + const w = Math.max(30, textUnits(conn.label) * 4.8 + 10); + labels.push({ + text: conn.label, + x: Math.round(lx - w / 2), + y: Math.round(ly - 10), + width: Math.round(w), + height: 14, + labelAt: [Math.round(lx), Math.round(ly)], + }); + } + return { + ok: true, + diagram_type: 'architecture', + layout: grid ? { mode: 'grid', ...grid } : { mode: 'free' }, + viewBox, + components: [...components.values()].map(componentBox), + boundaries: boundaries.map(boundaryBox), + connections: asArray(arch.connections) + .filter((conn) => components.has(conn.from) && components.has(conn.to)) + .map((conn) => { + const routed = pathFor(conn); + const labelAt = conn.label ? labelPoint(conn, routed.points) : null; + return connectionPath(conn, routed, labelAt); + }), + labels, + }; +} + +// ---- Connection routing ------------------------------------------------------ +function routeClearsComponents(conn, points, clearance = 2) { + const endpointIds = new Set([conn.from, conn.to]); + for (const component of components.values()) { + if (endpointIds.has(component.id)) continue; + for (let index = 0; index < points.length - 1; index += 1) { + if (segmentIntersectsRect({ start: points[index], end: points[index + 1] }, component, clearance)) { + return false; + } + } + } + return true; +} + +function routeClearsEndpointComponents(points, from, to) { + const lastSegment = points.length - 2; + for (let index = 0; index <= lastSegment; index += 1) { + const segment = { start: points[index], end: points[index + 1] }; + if (index > 0 && segmentIntersectsRect(segment, from)) return false; + if (index < lastSegment && segmentIntersectsRect(segment, to)) return false; + } + return true; +} + +const OUTWARD_SIDE_VECTOR = { + left: [-1, 0], + right: [1, 0], + top: [0, -1], + bottom: [0, 1], +}; + +function outwardStub(point, side, distance = 24) { + const [dx, dy] = OUTWARD_SIDE_VECTOR[side] || [0, 0]; + return [point[0] + dx * distance, point[1] + dy * distance]; +} + +function collinearBacktrack(a, b, c) { + const first = [b[0] - a[0], b[1] - a[1]]; + const second = [c[0] - b[0], c[1] - b[1]]; + const cross = first[0] * second[1] - first[1] * second[0]; + const dot = first[0] * second[0] + first[1] * second[1]; + return Math.abs(cross) <= 0.0001 && dot < -0.0001; +} + +function sideAwareBridgeCandidates(start, end, fromSide, toSide) { + const startStub = outwardStub(start, fromSide); + const endStub = outwardStub(end, toSide); + const rawCandidates = []; + const minimumBridge = 16; + const verticalSides = new Set(['top', 'bottom']); + const horizontalSides = new Set(['left', 'right']); + + // Port spreading can leave parallel-side anchors only a few pixels apart. + // Route through a bounded outside channel so we keep both endpoint normals + // without introducing a tiny, noisy connector between the two stubs. + if (verticalSides.has(fromSide) && verticalSides.has(toSide) + && Math.abs(start[0] - end[0]) < minimumBridge) { + for (const channelX of [ + Math.max(start[0], end[0]) + minimumBridge, + Math.min(start[0], end[0]) - minimumBridge, + ]) { + rawCandidates.push([ + startStub, + [channelX, startStub[1]], + [channelX, endStub[1]], + endStub, + ]); + } + } + if (horizontalSides.has(fromSide) && horizontalSides.has(toSide) + && Math.abs(start[1] - end[1]) < minimumBridge) { + for (const channelY of [ + Math.max(start[1], end[1]) + minimumBridge, + Math.min(start[1], end[1]) - minimumBridge, + ]) { + rawCandidates.push([ + startStub, + [startStub[0], channelY], + [endStub[0], channelY], + endStub, + ]); + } + } + + rawCandidates.push( + [startStub, [endStub[0], startStub[1]], endStub], + [startStub, [startStub[0], endStub[1]], endStub], + ); + return rawCandidates.map((candidate) => normalizeRoutePoints([start, ...candidate, end])) + .filter((points) => points.length >= 2) + .filter((points) => !collinearBacktrack(points[0], points[1], points[2] || points[1])) + .filter((points) => !collinearBacktrack(points.at(-3) || points.at(-2), points.at(-2), points.at(-1))) + .filter((points) => routeHonorsEndpointSides(points, fromSide, toSide)) + .map((points) => points.slice(1, -1)); +} + +const AUTOMATIC_PORT_CORNER_GUTTER = 16; +const AUTOMATIC_PORT_ALIGNMENT_DELTA = 16; + +function portHasCornerClearance(rect, side, point) { + if (side === 'left' || side === 'right') { + const inset = Math.min(AUTOMATIC_PORT_CORNER_GUTTER, rect.height / 2); + return point[1] >= rect.y + inset && point[1] <= rect.y + rect.height - inset; + } + if (side === 'top' || side === 'bottom') { + const inset = Math.min(AUTOMATIC_PORT_CORNER_GUTTER, rect.width / 2); + return point[0] >= rect.x + inset && point[0] <= rect.x + rect.width - inset; + } + return false; +} + +function alignFacingPorts(conn, from, to, start, end, fromSide, toSide, ports) { + const hasExplicitGeometry = ( + conn.via + || (conn.route && conn.route !== 'auto') + || conn.channelX !== undefined + || conn.channelY !== undefined + || conn.labelAt + ); + const horizontallyFacing = ( + (fromSide === 'right' && toSide === 'left') + || (fromSide === 'left' && toSide === 'right') + ); + const verticallyFacing = ( + (fromSide === 'bottom' && toSide === 'top') + || (fromSide === 'top' && toSide === 'bottom') + ); + if (hasExplicitGeometry || (!horizontallyFacing && !verticallyFacing)) return { start, end }; + + const fromSpread = Boolean(ports?.from); + const toSpread = Boolean(ports?.to); + if (fromSpread && toSpread) return { start, end }; + const hasExplicitSides = ( + (conn.fromSide && conn.fromSide !== 'auto') + || (conn.toSide && conn.toSide !== 'auto') + ); + if (!fromSpread && !toSpread && hasExplicitSides) return { start, end }; + + const alignmentDelta = horizontallyFacing + ? Math.abs(start[1] - end[1]) + : Math.abs(start[0] - end[0]); + if (alignmentDelta >= AUTOMATIC_PORT_ALIGNMENT_DELTA) return { start, end }; + + // Keep the shared endpoint's distinct spread slot and move only the + // relationship's unshared endpoint onto that axis. With no spread endpoint, + // retain the existing least-movement choice between the two facing sides. + // If both endpoints are shared, preserve the outside bridge so no competing + // port is silently collapsed. + const alignEndToStart = horizontallyFacing + ? { start, end: [end[0], start[1]] } + : { start, end: [start[0], end[1]] }; + const alignStartToEnd = horizontallyFacing + ? { start: [start[0], end[1]], end } + : { start: [end[0], start[1]], end }; + const candidates = fromSpread + ? [alignEndToStart] + : toSpread + ? [alignStartToEnd] + : [alignEndToStart, alignStartToEnd]; + for (const candidate of candidates) { + const points = [candidate.start, candidate.end]; + if (portHasCornerClearance(from, fromSide, candidate.start) + && portHasCornerClearance(to, toSide, candidate.end) + && routeHonorsEndpointSides(points, fromSide, toSide) + && routeClearsEndpointComponents(points, from, to) + && routeClearsComponents(conn, points)) { + return candidate; + } + } + return { start, end }; +} + +function routeVia(conn, from, to, start, end, fromSide, toSide) { + if (conn.via) return conn.via; + switch (conn.route || 'auto') { + case 'straight': + return []; + case 'orthogonal-h': { + const midX = (start[0] + end[0]) / 2; + return [[midX, start[1]], [midX, end[1]]]; + } + case 'orthogonal-v': { + const midY = (start[1] + end[1]) / 2; + return [[start[0], midY], [end[0], midY]]; + } + case 'auto': + default: { + // Direct line unless the anchors are clearly orthogonal-friendly. + const deltaX = Math.abs(start[0] - end[0]); + const deltaY = Math.abs(start[1] - end[1]); + if ((deltaX < 4 || deltaY < 4) && routeHonorsEndpointSides([start, end], fromSide, toSide)) return []; + + const rhythmBridge = automaticPortRhythmBridge(start, end, fromSide, toSide, { + accept: (points) => ( + routeClearsEndpointComponents(points, from, to) + && routeClearsComponents(conn, points) + ), + }); + if (rhythmBridge) return rhythmBridge.slice(1, -1); + + // Automatic port spreading can leave otherwise aligned endpoints only a + // few pixels apart. A midpoint route would split that tiny difference + // into two unreadable endpoint stubs, so take a bounded outside channel + // when both anchors sit on parallel component sides. + const minimumStub = 8; + const fromVerticalSide = start[1] === from.y || start[1] === from.y + from.height; + const toVerticalSide = end[1] === to.y || end[1] === to.y + to.height; + if (fromVerticalSide && toVerticalSide && deltaX < minimumStub * 2) { + const outsideChannels = [ + Math.max(start[0], end[0]) + minimumStub * 2, + Math.min(start[0], end[0]) - minimumStub * 2, + ]; + for (const channelX of outsideChannels) { + const candidate = [[channelX, start[1]], [channelX, end[1]]]; + const points = [start, ...candidate, end]; + if (routeHonorsEndpointSides(points, fromSide, toSide) && routeClearsComponents(conn, points)) return candidate; + } + } + + const fromHorizontalSide = start[0] === from.x || start[0] === from.x + from.width; + const toHorizontalSide = end[0] === to.x || end[0] === to.x + to.width; + if (fromHorizontalSide && toHorizontalSide && deltaY < minimumStub * 2) { + const outsideChannels = [ + Math.max(start[1], end[1]) + minimumStub * 2, + Math.min(start[1], end[1]) - minimumStub * 2, + ]; + for (const channelY of outsideChannels) { + const candidate = [[start[0], channelY], [end[0], channelY]]; + const points = [start, ...candidate, end]; + if (routeHonorsEndpointSides(points, fromSide, toSide) && routeClearsComponents(conn, points)) return candidate; + } + } + + const midX = (start[0] + end[0]) / 2; + const horizontalFirst = [[midX, start[1]], [midX, end[1]]]; + const midY = (start[1] + end[1]) / 2; + const verticalFirst = [[start[0], midY], [end[0], midY]]; + const candidates = [horizontalFirst, verticalFirst]; + const sideSafe = candidates.filter((candidate) => ( + routeHonorsEndpointSides([start, ...candidate, end], fromSide, toSide) + )); + const sideAware = sideAwareBridgeCandidates(start, end, fromSide, toSide); + const nearParallelPorts = ( + ((fromSide === 'top' || fromSide === 'bottom') + && (toSide === 'top' || toSide === 'bottom') + && deltaX < minimumStub * 2) + || ((fromSide === 'left' || fromSide === 'right') + && (toSide === 'left' || toSide === 'right') + && deltaY < minimumStub * 2) + ); + const ordered = [ + ...(nearParallelPorts ? sideAware : sideSafe), + ...(nearParallelPorts ? sideSafe : sideAware), + ...candidates.filter((candidate) => !sideSafe.includes(candidate)), + ]; + for (const candidate of ordered) { + const points = [start, ...candidate, end]; + if (routeClearsEndpointComponents(points, from, to) && routeClearsComponents(conn, points)) return candidate; + } + + // Both bounded doglegs are blocked. Keep the best endpoint-safe route + // when one exists so the universal Clean Flow gate reports the actual + // obstacle; otherwise preserve the historical deterministic fallback + // and let the endpoint-direction gate explain the side mismatch. + return sideSafe[0] || sideAware[0] || horizontalFirst; + } + } +} + +const pathCache = new Map(); +const automaticPorts = automaticPortSpread(arch.connections, components); +function connectionSides(conn) { + const from = components.get(conn.from); + const to = components.get(conn.to); + return { + fromSide: chosenSide(conn.fromSide, defaultFromSide(from, to)), + toSide: chosenSide(conn.toSide, defaultToSide(from, to)), + }; +} + +function connectionEndpointSide(conn, endpoint) { + const field = endpoint === 'source' ? 'fromSide' : 'toSide'; + if (conn[field] && conn[field] !== 'auto') return conn[field]; + return connectionSides(conn)[field]; +} + +function pathFor(conn) { + if (pathCache.has(conn)) return pathCache.get(conn); + const from = components.get(conn.from); + const to = components.get(conn.to); + const ports = automaticPorts.get(conn); + const { fromSide, toSide } = connectionSides(conn); + const baseStart = ports?.from || anchor(from, fromSide); + const baseEnd = ports?.to || anchor(to, toSide); + const { start, end } = alignFacingPorts( + conn, + from, + to, + baseStart, + baseEnd, + fromSide, + toSide, + ports, + ); + const points = [start, ...routeVia(conn, from, to, start, end, fromSide, toSide), end]; + const routed = { d: roundedPath(points, 8), points }; + pathCache.set(conn, routed); + return routed; +} + +// ---- Rendering --------------------------------------------------------------- +function renderBoundaryFrame(b, index) { + const cls = b.kind === 'security-group' ? 'c-security-group' : 'c-region'; + const rx = b.kind === 'security-group' ? 8 : 12; + return ` `; +} + +function renderBoundaryLabel(b, index) { + const labelCls = b.kind === 'security-group' ? 't-security' : 't-cloud'; + return ` + + ${esc(b.label)} + `; +} + +function renderConnectionPath(conn, index) { + const [cls, marker] = arrowClassMap[conn.variant || 'default'] || arrowClassMap.default; + const routed = pathFor(conn); + const strokeWidth = conn.width || (conn.variant === 'emphasis' ? 1.8 : 1.5); + return ` `; +} + +function renderConnectionLabel(conn, index) { + if (!conn.label) return ''; + const [lx, ly] = labelPoint(conn, pathFor(conn).points); + const w = Math.max(30, textUnits(conn.label) * 4.8 + 10); + return ` + + ${esc(conn.label)} + `; +} + +function renderComponent(c) { + const fill = componentFill[c.type] || 'c-external'; + const accent = componentText[c.type] || 't-muted'; + const cx = c.cx; + const hasSub = c.sublabel != null && c.sublabel !== ''; + const labelY = hasSub ? c.y + c.height / 2 - 2 : c.y + c.height / 2 + 4; + const sub = hasSub + ? `\n ${esc(c.sublabel)}` + : ''; + const tag = c.tag + ? `\n ${esc(c.tag)}` + : ''; + const labelFontSize = fittedNodeFontSize(c.label, c.width, 11, 8); + const passport = { kind: c.semantic_kind || c.type, sublabel: c.sublabel, tag: c.tag, context: componentContext(c) }; + return ` + ${focusNodeTitle(c.label, passport)} + + + ${renderSemanticSigil(c.type, { x: c.x + 6, y: c.y + 6 })} + ${esc(c.label)}${sub}${tag} + `; +} + +function renderSvg() { + return ` +${svgAccessibleText(arch.meta, 'architecture')} +${renderDefinitions()} + + + + + +${boundaries.map(renderBoundaryFrame).join('\n\n')} + + +${asArray(arch.connections).map(renderConnectionPath).join('\n')} + + +${[...components.values()].map(renderComponent).join('\n\n')} + + +${asArray(arch.connections).map(renderConnectionLabel).join('\n')} + + +${boundaries.map(renderBoundaryLabel).join('\n\n')} + `; +} + +validateArchitecture(); +if (layoutJsonMode) { + console.log(JSON.stringify(buildLayoutReport(), null, 2)); + process.exit(0); +} +writeDiagram({ + outPath, + template, + diagramType: 'architecture', + meta: arch.meta, + svg: renderSvg(), +}); diff --git a/visualization/archify-core/renderers/shared/cli.mjs b/visualization/archify-core/renderers/shared/cli.mjs new file mode 100644 index 0000000..a3aaa13 --- /dev/null +++ b/visualization/archify-core/renderers/shared/cli.mjs @@ -0,0 +1,115 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { applyTemplate, esc } from './utils.mjs'; +import { validateSchema } from './validator.mjs'; +import { installRendererDiagnosticBoundary, throwDiagnosticProblems } from './diagnostics.mjs'; +import { resolveLocale, translateMessage } from './i18n.mjs'; + +installRendererDiagnosticBoundary(); + +// Cleanup Map always renders a generated Architecture document to an explicit +// artifact path; it does not need Archify's repository-evidence or output-path +// workflows for arbitrary authoring inputs. +export function loadDiagram({ rendererDir, diagramType, defaultExample, argv = process.argv }) { + const skillRoot = path.resolve(rendererDir, '../..'); + const inputPath = path.resolve(argv[2] || path.join(skillRoot, 'examples', defaultExample)); + const diagram = JSON.parse(fs.readFileSync(inputPath, 'utf8')); + validateSchema(diagramType, diagram); + validateRelationshipIds(diagram); + const template = fs.readFileSync(path.join(skillRoot, 'assets/template.html'), 'utf8'); + const outPath = path.resolve(argv[3] || `${diagramType}.html`); + return { diagram, template, outPath }; +} + +export function writeDiagram({ outPath, template, diagramType, meta, svg }) { + if (diagramType !== 'architecture') throw new Error(`writeDiagram: cleanup core supports architecture only, received ${JSON.stringify(diagramType)}`); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, applyTemplate(template, { + title: meta.title, + subtitle: meta.subtitle, + svg, + locale: meta.locale, + visualPreset: meta.visual_preset || 'signal-flow', + })); + console.log(outPath); +} + +function validateRelationshipIds(diagram) { + const collection = 'connections'; + const relationships = Array.isArray(diagram[collection]) ? diagram[collection] : []; + const seen = new Set(); + const problems = []; + + relationships.forEach((relationship, index) => { + if (relationship.id === undefined || relationship.id === null || relationship.id === '') return; + if (seen.has(relationship.id)) { + problems.push(`/${collection}/${index}/id duplicates relationship id ${JSON.stringify(relationship.id)}`); + } + seen.add(relationship.id); + }); + + if (problems.length) { + throwDiagnosticProblems('Relationship identity validation failed', problems, { + code: 'relationship/duplicate-id', + subject: { diagramType: 'architecture', collection }, + }); + } +} + +// Accessible name for the generated diagram SVG. +export function svgRootAttrs(meta, kind) { + const preset = ` data-preset="${esc(meta.visual_preset || 'signal-flow')}"`; + const engineeringProfile = meta.engineering_profile + ? ` data-engineering-profile="${esc(meta.engineering_profile)}"` + : ''; + const requestedProfile = process.env.ARCHIFY_QUALITY_PROFILE || meta.quality_profile; + const qualityProfile = requestedProfile === 'showcase' ? 'showcase' : 'standard'; + const advisory = requestedProfile ? '' : ' data-quality-gates="advisory"'; + return `role="img" lang="${esc(resolveLocale(meta.locale))}" aria-labelledby="archify-diagram-title archify-diagram-description"${preset}${engineeringProfile} data-quality-profile="${esc(qualityProfile)}"${advisory}`; +} + +// Keep the accessible name inside the SVG so it survives standalone SVG +// export and embedding. The fixed IDs are deterministic because an Archify +// artifact intentionally contains one primary diagram SVG. +export function svgAccessibleText(meta, kind) { + const description = meta.subtitle || translateMessage(meta.locale, `diagram.description.${kind}`); + return ` ${esc(meta.title)}\n ${esc(description)}`; +} + +// Stable semantic hooks for the standalone HTML explorer. IDs already pass +// the schema's conservative identifier pattern; escape again at the markup +// boundary so these helpers remain safe if that contract expands later. +export function focusNodeAttrs(id, label, metadata = {}, locale) { + const optional = [ + ['data-node-kind', metadata.kind], + ['data-node-sublabel', metadata.sublabel], + ['data-node-tag', metadata.tag], + ['data-node-context', metadata.context], + ].filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '') + .map(([name, value]) => ` ${name}="${esc(String(value))}"`) + .join(''); + const detail = [metadata.sublabel, metadata.context] + .filter((value) => value !== undefined && value !== null && String(value).trim() !== '') + .join(', '); + const aria = detail + ? translateMessage(locale, 'node.focus.detail', { label, detail }) + : translateMessage(locale, 'node.focus', { label }); + return `id="node-${esc(id)}" data-node-id="${esc(id)}" data-node-label="${esc(label)}" tabindex="0" role="button" aria-label="${esc(aria)}" aria-pressed="false"${optional}`; +} + +// Native SVG titles preserve a compact details-on-demand fallback when the +// canonical SVG is embedded inline outside the full Archify viewer. +export function focusNodeTitle(label, metadata = {}) { + const parts = [label, metadata.sublabel, metadata.context, metadata.tag] + .filter((value) => value !== undefined && value !== null && String(value).trim() !== ''); + return `${esc(parts.join(' · '))}`; +} + +export function focusEdgeAttrs(from, to, label, key, id) { + const named = label ? ` data-edge-label="${esc(label)}"` : ''; + const keyed = key !== undefined && key !== null ? ` data-edge-key="${esc(String(key))}"` : ''; + const identified = id !== undefined && id !== null && String(id).trim() !== '' + ? ` data-edge-id="${esc(String(id))}"` + : ''; + return `data-edge-from="${esc(from)}" data-edge-to="${esc(to)}"${named}${keyed}${identified}`; +} diff --git a/visualization/archify-core/renderers/shared/desktop-readability.mjs b/visualization/archify-core/renderers/shared/desktop-readability.mjs new file mode 100644 index 0000000..1a4c249 --- /dev/null +++ b/visualization/archify-core/renderers/shared/desktop-readability.mjs @@ -0,0 +1,26 @@ +export const DESKTOP_READABILITY_VIEWPORT = Object.freeze({ width: 1440, height: 900 }); +export const DESKTOP_READER_MIN_WIDTH = 960; +export const DESKTOP_READER_HORIZONTAL_CHROME = 30; +export const DESKTOP_READER_DIAGRAM_WIDTH = DESKTOP_READER_MIN_WIDTH - DESKTOP_READER_HORIZONTAL_CHROME; +export const MIN_PROJECTED_NODE_TEXT_PX = 6; + +export function projectedNodeTextPx(sourceFontPx, viewBoxWidth, diagramWidth = DESKTOP_READER_DIAGRAM_WIDTH) { + if (![sourceFontPx, viewBoxWidth, diagramWidth].every(Number.isFinite) || viewBoxWidth <= 0 || diagramWidth <= 0) { + return Number.NaN; + } + return sourceFontPx * Math.min(1, diagramWidth / viewBoxWidth); +} + +export function minimumReadableSourceTextPx( + viewBoxWidth, + diagramWidth = DESKTOP_READER_DIAGRAM_WIDTH, + minimumProjectedPx = MIN_PROJECTED_NODE_TEXT_PX, +) { + if (![viewBoxWidth, diagramWidth, minimumProjectedPx].every(Number.isFinite) + || viewBoxWidth <= 0 + || diagramWidth <= 0 + || minimumProjectedPx <= 0) { + return Number.NaN; + } + return minimumProjectedPx / Math.min(1, diagramWidth / viewBoxWidth); +} diff --git a/visualization/archify-core/renderers/shared/diagnostics.mjs b/visualization/archify-core/renderers/shared/diagnostics.mjs new file mode 100644 index 0000000..f392f03 --- /dev/null +++ b/visualization/archify-core/renderers/shared/diagnostics.mjs @@ -0,0 +1,116 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const DIAGNOSTIC_MODE = process.env.ARCHIFY_DIAGNOSTIC_FORMAT === 'json'; +const recorded = []; +const recordedMessages = new Set(); +const boundaryKey = Symbol.for('archify.renderer-diagnostic-boundary'); + +function plainObject(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)); +} + +function normalizedDiagnostic(diagnostic) { + const message = String(diagnostic?.message || 'Archify could not classify this failure.').trim(); + return { + code: String(diagnostic?.code || 'internal/unclassified'), + severity: diagnostic?.severity === 'warning' ? 'warning' : 'error', + message, + subject: plainObject(diagnostic?.subject), + evidence: plainObject(diagnostic?.evidence), + supportedFixes: Array.isArray(diagnostic?.supportedFixes) + ? [...new Set(diagnostic.supportedFixes.map((fix) => String(fix).trim()).filter(Boolean))] + : [], + }; +} + +export function recordDiagnostic(diagnostic) { + if (!DIAGNOSTIC_MODE) return; + const normalized = normalizedDiagnostic(diagnostic); + if (recordedMessages.has(normalized.message)) return; + recordedMessages.add(normalized.message); + recorded.push(normalized); +} + +export function throwDiagnosticError(message, diagnostics) { + for (const diagnostic of diagnostics || []) recordDiagnostic(diagnostic); + const error = new Error(message); + error.archifyDiagnostics = (diagnostics || []).map(normalizedDiagnostic); + throw error; +} + +export function throwDiagnosticProblems(prefix, problems, { code = 'layout/constraint', subject = {} } = {}) { + const messages = (problems || []).map((problem) => String(problem)); + for (const message of messages) { + recordDiagnostic({ + code, + severity: 'error', + message, + subject, + evidence: {}, + supportedFixes: [], + }); + } + throw new Error(`${prefix}:\n- ${messages.join('\n- ')}`); +} + +function fallbackDiagnostic(error) { + const input = process.argv[2] ? path.resolve(process.argv[2]) : undefined; + if (error instanceof SyntaxError) { + return normalizedDiagnostic({ + code: 'input/json-parse', + severity: 'error', + message: `Input JSON could not be parsed: ${error.message}`, + subject: { input }, + evidence: { reason: error.message }, + supportedFixes: ['repair the JSON syntax and run validation again'], + }); + } + if (error?.code === 'ENOENT' || error?.code === 'EACCES' || error?.code === 'EISDIR') { + return normalizedDiagnostic({ + code: 'input/read', + severity: 'error', + message: `Input could not be read: ${error.message}`, + subject: { input }, + evidence: { systemCode: error.code, reason: error.message }, + supportedFixes: ['provide one readable JSON input file'], + }); + } + return normalizedDiagnostic({ + code: 'internal/unclassified', + severity: 'error', + message: error?.message || 'Renderer failed without a diagnostic.', + subject: { input }, + evidence: { errorName: error?.name || 'Error' }, + supportedFixes: [], + }); +} +function rendererFailure(error) { + const attached = Array.isArray(error?.archifyDiagnostics) + ? error.archifyDiagnostics.map(normalizedDiagnostic) + : []; + const diagnostics = recorded.length ? recorded : (attached.length ? attached : [fallbackDiagnostic(error)]); + return { + schemaVersion: 1, + ok: false, + source: 'renderer', + error: error?.message || 'Renderer failed without a diagnostic.', + diagnostics, + }; +} + +export function installRendererDiagnosticBoundary() { + if (!DIAGNOSTIC_MODE || globalThis[boundaryKey]) return; + globalThis[boundaryKey] = true; + process.on('uncaughtException', (error) => { + const payload = `${JSON.stringify(rendererFailure(error))}\n`; + try { + fs.writeSync(process.stderr.fd, payload); + } catch { + // The renderer is already failing. Avoid replacing its real error with a + // secondary stream failure; the parent CLI still has the exit status. + } + process.exit(1); + }); +} diff --git a/visualization/archify-core/renderers/shared/geometry.mjs b/visualization/archify-core/renderers/shared/geometry.mjs new file mode 100644 index 0000000..7b091f1 --- /dev/null +++ b/visualization/archify-core/renderers/shared/geometry.mjs @@ -0,0 +1,1334 @@ +// Geometry helpers shared by all typed renderers. Every function here is +// pure; renderers own their layout tables and pass measured rects +// ({x, y, width, height, cx, cy}) in. + +import { recordDiagnostic } from './diagnostics.mjs'; + +// In degraded mode (no ajv) a type-wrong top-level field reaches the renderer. +// Coerce non-arrays to [] so the module-level Maps build without throwing and +// the friendly validator checks (which run later) report the real problem. +export function asArray(value) { + return Array.isArray(value) ? value : []; +} + +// A computed coordinate must be a finite number; NaN/undefined would silently +// write `` into the output. Used by the validators as a backstop. +export function isFinitePoint(...coords) { + return coords.every((c) => Number.isFinite(c)); +} + +export function rectsOverlap(a, b, gap = 0) { + // Non-finite geometry means "unknown", not "overlapping". Every comparison + // below is false for NaN, so without this guard the negation reports a + // collision for every pair. Callers surface non-finite pos/size through their + // own diagnostic; reporting it again as an overlap buries that message under + // one bogus separation hint per pair. + if (!isFinitePoint(a.x, a.y, a.width, a.height, b.x, b.y, b.width, b.height)) { + return false; + } + return !( + a.x + a.width + gap <= b.x || + b.x + b.width + gap <= a.x || + a.y + a.height + gap <= b.y || + b.y + b.height + gap <= a.y + ); +} + +export function segmentIntersectsRect(segment, rect, gap = 0) { + const box = { + x1: rect.x - gap, + y1: rect.y - gap, + x2: rect.x + rect.width + gap, + y2: rect.y + rect.height + gap + }; + const [a, b] = [segment.start, segment.end]; + if (pointInBox(a, box) || pointInBox(b, box)) return true; + return ( + segmentsIntersect(a, b, [box.x1, box.y1], [box.x2, box.y1]) || + segmentsIntersect(a, b, [box.x2, box.y1], [box.x2, box.y2]) || + segmentsIntersect(a, b, [box.x2, box.y2], [box.x1, box.y2]) || + segmentsIntersect(a, b, [box.x1, box.y2], [box.x1, box.y1]) + ); +} + +export function segmentRectClearance(segment, rect) { + if (!segment || !rect) return null; + const { start, end } = segment; + if (!Array.isArray(start) || !Array.isArray(end) || start.length !== 2 || end.length !== 2) return null; + if (!isFinitePoint(...start, ...end, rect.x, rect.y, rect.width, rect.height)) return null; + if (rect.width < 0 || rect.height < 0) return null; + if (segmentIntersectsRect(segment, rect)) return 0; + + const corners = [ + [rect.x, rect.y], + [rect.x + rect.width, rect.y], + [rect.x + rect.width, rect.y + rect.height], + [rect.x, rect.y + rect.height], + ]; + return Math.min( + pointRectDistance(start, rect), + pointRectDistance(end, rect), + ...corners.map((corner) => pointSegmentDistance(corner, start, end)), + ); +} + +export function segmentRectIntersectionLength(segment, rect) { + if (!segment || !rect) return null; + const { start, end } = segment; + if (!Array.isArray(start) || !Array.isArray(end) || start.length !== 2 || end.length !== 2) return null; + if (!isFinitePoint(...start, ...end, rect.x, rect.y, rect.width, rect.height)) return null; + if (rect.width < 0 || rect.height < 0) return null; + + const dx = end[0] - start[0]; + const dy = end[1] - start[1]; + const length = Math.hypot(dx, dy); + if (length <= 0.0000001) return 0; + const bounds = [ + [-dx, start[0] - rect.x], + [dx, rect.x + rect.width - start[0]], + [-dy, start[1] - rect.y], + [dy, rect.y + rect.height - start[1]], + ]; + let enter = 0; + let leave = 1; + for (const [direction, distance] of bounds) { + if (Math.abs(direction) <= 0.0000001) { + if (distance < -0.0000001) return 0; + continue; + } + const ratio = distance / direction; + if (direction < 0) enter = Math.max(enter, ratio); + else leave = Math.min(leave, ratio); + if (enter > leave + 0.0000001) return 0; + } + return length * Math.max(0, leave - enter); +} + +export function collectLabelRouteClearance({ labels, routedRelations, threshold }) { + if (!Number.isFinite(threshold) || threshold < 0) return []; + const routeCandidates = asArray(routedRelations).map((entry, fallbackIndex) => { + const relation = entry?.relation || entry; + const points = normalizeRoutePoints(entry?.points || relation?.routePoints); + if (!relation || points.length < 2) return null; + return { + relation, + relationIndex: Number.isInteger(entry?.relationIndex) ? entry.relationIndex : fallbackIndex, + points, + }; + }).filter(Boolean); + const seenRoutes = new Set(); + const routes = routeCandidates.filter((route) => { + const identity = relationshipIdentity(route.relation, route.relationIndex); + if (seenRoutes.has(identity)) return false; + seenRoutes.add(identity); + return true; + }); + const hits = []; + const seenLabels = new Set(); + + for (const [fallbackIndex, label] of asArray(labels).entries()) { + const rect = label?.rect || label; + if (!rect || !isFinitePoint(rect.x, rect.y, rect.width, rect.height) || rect.width < 0 || rect.height < 0) continue; + const relationIndex = Number.isInteger(label?.relationIndex) ? label.relationIndex : fallbackIndex; + const labelIdentity = relationshipIdentity(label?.relation, relationIndex); + if (seenLabels.has(labelIdentity)) continue; + seenLabels.add(labelIdentity); + for (const route of routes) { + if (relationIndex === route.relationIndex || sameRelationship(label?.relation, route.relation)) continue; + let nearest = null; + for (let segmentIndex = 0; segmentIndex < route.points.length - 1; segmentIndex += 1) { + const start = route.points[segmentIndex]; + const end = route.points[segmentIndex + 1]; + const clearance = segmentRectClearance({ start, end }, rect); + if (clearance == null) continue; + if (!nearest || clearance < nearest.clearance) { + nearest = { + clearance, + intersectionLength: segmentRectIntersectionLength({ start, end }, rect), + segmentIndex, + start, + end, + }; + } + } + if (!nearest || nearest.clearance + 0.0001 >= threshold) continue; + hits.push({ + label, + labelRelation: label?.relation, + labelRelationIndex: relationIndex, + otherRelation: route.relation, + otherRelationIndex: route.relationIndex, + rect, + ...nearest, + threshold, + }); + } + } + return hits; +} + +function relationshipIdentity(relation, relationIndex) { + if (relation?.key !== undefined) return `key:${relation.key}`; + if (relation?.id) return `id:${relation.from || ''}\u0000${relation.to || ''}\u0000${relation.id}`; + return `index:${relationIndex}`; +} + +function sameRelationship(left, right) { + if (!left || !right) return false; + if (left === right) return true; + if (left.key !== undefined && right.key !== undefined) return left.key === right.key; + return Boolean(left.id && right.id && left.id === right.id && left.from === right.from && left.to === right.to); +} + +function relationshipSubject(diagramType, relationCollection, relationIndex, relation) { + return { + diagramType, + collection: relationCollection, + index: relationIndex, + ...(relation?.id ? { id: relation.id } : {}), + ...(relation?.from ? { from: relation.from } : {}), + ...(relation?.to ? { to: relation.to } : {}), + }; +} + +const ENDPOINT_SIDE_RULES = { + left: { + axis: 'horizontal', + sourceSign: -1, + targetSign: 1, + sourceDirection: 'leftward', + targetDirection: 'rightward from the left', + }, + right: { + axis: 'horizontal', + sourceSign: 1, + targetSign: -1, + sourceDirection: 'rightward', + targetDirection: 'leftward from the right', + }, + top: { + axis: 'vertical', + sourceSign: -1, + targetSign: 1, + sourceDirection: 'upward', + targetDirection: 'downward from above', + }, + bottom: { + axis: 'vertical', + sourceSign: 1, + targetSign: -1, + sourceDirection: 'downward', + targetDirection: 'upward from below', + }, +}; + +function endpointSideIssue(points, endpoint, side) { + const rule = ENDPOINT_SIDE_RULES[side]; + if (!rule) return null; + const normalized = normalizeRoutePoints(points); + if (normalized.length < 2) return null; + const segmentIndex = endpoint === 'source' ? 0 : normalized.length - 2; + const start = normalized[segmentIndex]; + const end = normalized[segmentIndex + 1]; + const dx = end[0] - start[0]; + const dy = end[1] - start[1]; + const along = rule.axis === 'horizontal' ? dx : dy; + const across = rule.axis === 'horizontal' ? dy : dx; + const expectedSign = endpoint === 'source' ? rule.sourceSign : rule.targetSign; + if (Math.abs(across) <= 0.0001 && along * expectedSign > 0.0001) return null; + return { + endpoint, + side, + segmentIndex, + start, + end, + expectedAxis: rule.axis, + expectedDirection: endpoint === 'source' ? rule.sourceDirection : rule.targetDirection, + }; +} + +// A side is a direction contract, not just a point on a box border. This pure +// predicate lets automatic routers prefer a dogleg whose first and final +// segments leave/enter the chosen sides perpendicularly. +export function routeHonorsEndpointSides(points, fromSide, toSide) { + return !endpointSideIssue(points, 'source', fromSide) + && !endpointSideIssue(points, 'target', toSide); +} + +// Explicit fromSide/toSide are authored geometry, so a tangent or backwards +// endpoint segment changes their meaning. Fail this universally instead of +// leaving a malformed arrow for visual review to discover. Named routes and +// authored via points already carry their own geometry semantics: when they +// omit endpoint sides, do not invent a relative-position side and then reject +// the route for disagreeing with that invention. Pure automatic routes may +// still be checked against renderer-inferred sides. +export function cleanEndpointSideProblems({ + relations, + endpointIds, + pathFor, + diagramType, + relationCollection, + fromSideFor, + toSideFor, + shouldCheckRelation = () => true, + routeHint = 'align the first/final via segment with fromSide/toSide, change the side, or remove explicit routing so auto can choose a perpendicular approach', +}) { + const problems = []; + for (const [relationIndex, relation] of asArray(relations).entries()) { + if (!relation || !endpointIds?.has(relation.from) || !endpointIds?.has(relation.to)) continue; + if (!shouldCheckRelation(relation, relationIndex)) continue; + const points = pathFor(relation)?.points; + if (!Array.isArray(points) || points.length < 2) continue; + const authoredFromSide = relation.fromSide && relation.fromSide !== 'auto' ? relation.fromSide : null; + const authoredToSide = relation.toSide && relation.toSide !== 'auto' ? relation.toSide : null; + const hasAuthoredRouteGeometry = Boolean( + (relation.route && relation.route !== 'auto') || Array.isArray(relation.via), + ); + const inferredFromSide = !hasAuthoredRouteGeometry && typeof fromSideFor === 'function' + ? fromSideFor(relation) + : null; + const inferredToSide = !hasAuthoredRouteGeometry && typeof toSideFor === 'function' + ? toSideFor(relation) + : null; + const fromSide = authoredFromSide ?? inferredFromSide; + const toSide = authoredToSide ?? inferredToSide; + const checks = [ + fromSide + ? { ...endpointSideIssue(points, 'source', fromSide), sideOrigin: authoredFromSide ? 'authored' : 'inferred' } + : null, + toSide + ? { ...endpointSideIssue(points, 'target', toSide), sideOrigin: authoredToSide ? 'authored' : 'inferred' } + : null, + ].filter((issue) => issue?.endpoint); + for (const issue of checks) { + const relationId = relation.id ? ` id "${relation.id}"` : ''; + const authoredField = issue.endpoint === 'source' ? 'fromSide' : 'toSide'; + const sideField = issue.sideOrigin === 'inferred' ? `inferred ${authoredField}` : authoredField; + const segmentRole = issue.endpoint === 'source' ? 'first' : 'final'; + const from = issue.start.map((value) => Math.round(value * 10) / 10).join(', '); + const to = issue.end.map((value) => Math.round(value * 10) / 10).join(', '); + const message = `[clean-flow/endpoint-side-direction] ${diagramType} ${relationCollection}[${relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" ${segmentRole} segment ${issue.segmentIndex} [${from}] -> [${to}] does not honor ${sideField} "${issue.side}" — it must run ${issue.expectedAxis} ${issue.expectedDirection}; ${routeHint}.`; + recordDiagnostic({ + code: 'clean-flow/endpoint-side-direction', + severity: 'error', + message, + subject: relationshipSubject(diagramType, relationCollection, relationIndex, relation), + evidence: { + endpoint: issue.endpoint, + authoredField, + sideOrigin: issue.sideOrigin, + side: issue.side, + segmentIndex: issue.segmentIndex, + from: issue.start, + to: issue.end, + expectedAxis: issue.expectedAxis, + expectedDirection: issue.expectedDirection, + }, + supportedFixes: [routeHint], + }); + problems.push(message); + } + } + return problems; +} + +// One mechanical quality gate for every renderer-owned relationship path. +// A renderer supplies its semantic obstacle set; source/target boxes are +// always exempt because paths are expected to terminate on their boundaries. +// Containers, lifelines, and other intentionally pass-through geometry should +// simply not be supplied as obstacles. +export function cleanFlowProblems({ + relations, + obstacles, + pathFor, + diagramType, + relationCollection, + obstacleKind, + profile, + clearance = 2, + routeHint = 'adjust fromSide/toSide, set route/via or channel coordinates, or move the obstacle' +}) { + // A relationship hidden behind an unrelated opaque node changes the + // diagram's meaning, so this is a correctness invariant rather than an + // opt-in composition preference. Keep it active even when the author omits + // quality_profile; standard/showcase still control stricter visual budgets. + const problems = []; + const obstacleList = [...obstacles]; + const obstacleIds = new Set(obstacleList.map((obstacle) => obstacle?.id)); + for (const [relationIndex, relation] of asArray(relations).entries()) { + if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') continue; + if (!obstacleIds.has(relation.from) || !obstacleIds.has(relation.to)) continue; + const points = pathFor(relation)?.points; + if (!Array.isArray(points) || points.length < 2) continue; + if (!points.every((point) => Array.isArray(point) && point.length === 2 && isFinitePoint(...point))) continue; + + const endpointIds = new Set([relation.from, relation.to]); + for (const obstacle of obstacleList) { + if (!obstacle || endpointIds.has(obstacle.id)) continue; + if (!isFinitePoint(obstacle.x, obstacle.y, obstacle.width, obstacle.height)) continue; + let hitSegment = -1; + for (let segmentIndex = 0; segmentIndex < points.length - 1; segmentIndex += 1) { + if (segmentIntersectsRect({ start: points[segmentIndex], end: points[segmentIndex + 1] }, obstacle, clearance)) { + hitSegment = segmentIndex; + break; + } + } + if (hitSegment === -1) continue; + const from = points[hitSegment].map(Math.round).join(', '); + const to = points[hitSegment + 1].map(Math.round).join(', '); + const relationId = relation.id ? ` id "${relation.id}"` : ''; + const message = `[clean-flow/edge-through-node] ${diagramType} ${relationCollection}[${relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" crosses ${obstacleKind} "${obstacle.id}" (unrelated to this relationship) on segment ${hitSegment} [${from}] -> [${to}] (${clearance}px clearance) — ${routeHint}.`; + recordDiagnostic({ + code: 'clean-flow/edge-through-node', + severity: 'error', + message, + subject: relationshipSubject(diagramType, relationCollection, relationIndex, relation), + evidence: { + obstacleKind, + obstacleId: obstacle.id, + segmentIndex: hitSegment, + from: points[hitSegment], + to: points[hitSegment + 1], + clearancePx: clearance, + }, + supportedFixes: [routeHint], + }); + problems.push(message); + } + } + return problems; +} + +// Reject only a proper interior X between relationships that share no semantic +// endpoint. Endpoint touches, branch/merge ports, and collinear shared +// corridors are intentionally outside this contract because geometry alone +// cannot tell whether those are authored junctions. +export function cleanCrossingProblems({ + relations, + endpointIds, + pathFor, + diagramType, + relationCollection, + profile = 'standard', + routeHint = 'adjust route/via or channel coordinates so the relationships use separate corridors' +}) { + const requestedProfile = process.env.ARCHIFY_QUALITY_PROFILE || profile; + const activeProfile = requestedProfile === 'showcase' ? 'showcase' : 'standard'; + if (activeProfile !== 'showcase') return []; + const routed = asArray(relations).map((relation, index) => { + if (!relation || !endpointIds.has(relation.from) || !endpointIds.has(relation.to)) return null; + const points = pathFor(relation)?.points; + if (!Array.isArray(points) || points.length < 2) return null; + if (!points.every((point) => Array.isArray(point) && point.length === 2 && isFinitePoint(...point))) return null; + return { relation, index, points }; + }).filter(Boolean); + const problems = []; + + for (let leftIndex = 0; leftIndex < routed.length; leftIndex += 1) { + const left = routed[leftIndex]; + for (let rightIndex = leftIndex + 1; rightIndex < routed.length; rightIndex += 1) { + const right = routed[rightIndex]; + if ([left.relation.from, left.relation.to].some((id) => id === right.relation.from || id === right.relation.to)) continue; + + let hit = null; + for (let leftSegment = 0; leftSegment < left.points.length - 1 && !hit; leftSegment += 1) { + for (let rightSegment = 0; rightSegment < right.points.length - 1; rightSegment += 1) { + const point = properSegmentIntersection( + left.points[leftSegment], + left.points[leftSegment + 1], + right.points[rightSegment], + right.points[rightSegment + 1] + ); + if (point) { + hit = { point, leftSegment, rightSegment }; + break; + } + } + } + if (!hit) continue; + + const describe = ({ relation, index }) => { + const id = relation.id ? ` id "${relation.id}"` : ''; + return `${relationCollection}[${index}]${id} "${relation.from}" -> "${relation.to}"`; + }; + const point = hit.point.map((value) => Math.round(value * 10) / 10).join(', '); + const message = `[composition/proper-crossing] showcase ${diagramType} ${describe(left)} crosses ${describe(right)} at [${point}] (segments ${hit.leftSegment} and ${hit.rightSegment}) — ${routeHint}.`; + recordDiagnostic({ + code: 'composition/proper-crossing', + severity: 'error', + message, + subject: relationshipSubject(diagramType, relationCollection, left.index, left.relation), + evidence: { + otherRelationship: relationshipSubject(diagramType, relationCollection, right.index, right.relation), + point: hit.point, + segmentIndex: hit.leftSegment, + otherSegmentIndex: hit.rightSegment, + }, + supportedFixes: [routeHint], + }); + problems.push(message); + } + } + return problems; +} + +// Two unrelated relationships that occupy the same visible corridor can read +// as one authored branch or merge even when neither relationship crosses a +// node or forms a proper X. Keep shared semantic endpoints exempt: their +// initial/final fan-out is real topology. Tiny overlaps below the route rhythm +// floor are ignored to avoid turning sub-pixel rounding into a quality debt. +export function collectAmbiguousCorridors({ + routedRelations, + minOverlapPx = 8, +}) { + const routed = asArray(routedRelations).map((entry, fallbackIndex) => { + const relation = entry?.relation; + if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null; + const points = normalizeRoutePoints(entry?.points); + if (points.length < 2) return null; + return { + relation, + relationIndex: Number.isInteger(entry.relationIndex) ? entry.relationIndex : fallbackIndex, + points, + }; + }).filter(Boolean); + const hits = []; + + for (let leftIndex = 0; leftIndex < routed.length; leftIndex += 1) { + const left = routed[leftIndex]; + for (let rightIndex = leftIndex + 1; rightIndex < routed.length; rightIndex += 1) { + const right = routed[rightIndex]; + if ([left.relation.from, left.relation.to].some((id) => id === right.relation.from || id === right.relation.to)) continue; + + let longest = null; + for (let leftSegment = 0; leftSegment < left.points.length - 1; leftSegment += 1) { + for (let rightSegment = 0; rightSegment < right.points.length - 1; rightSegment += 1) { + const overlap = collinearAxisOverlap( + left.points[leftSegment], + left.points[leftSegment + 1], + right.points[rightSegment], + right.points[rightSegment + 1], + ); + if (!overlap || overlap.length + 0.0001 < minOverlapPx) continue; + if (!longest || overlap.length > longest.overlapLength + 0.0001) { + longest = { + left, + right, + leftSegment, + rightSegment, + overlapLength: overlap.length, + overlapStart: overlap.start, + overlapEnd: overlap.end, + }; + } + } + } + if (longest) hits.push(longest); + } + } + return hits; +} + +export function cleanAmbiguousCorridorProblems({ + relations, + endpointIds, + pathFor, + diagramType, + relationCollection, + profile = 'standard', + routeHint = 'adjust route/via or channel coordinates so the relationships use separate corridors', + minOverlapPx = 8, +}) { + const requestedProfile = process.env.ARCHIFY_QUALITY_PROFILE || profile; + if (requestedProfile !== 'showcase') return []; + const routedRelations = asArray(relations).map((relation, relationIndex) => { + if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null; + if (endpointIds && (!endpointIds.has(relation.from) || !endpointIds.has(relation.to))) return null; + return { relation, relationIndex, points: pathFor(relation)?.points }; + }).filter(Boolean); + + return collectAmbiguousCorridors({ routedRelations, minOverlapPx }).map((hit) => { + const describe = ({ relation, relationIndex }) => { + const id = relation.id ? ` id "${relation.id}"` : ''; + return `${relationCollection}[${relationIndex}]${id} "${relation.from}" -> "${relation.to}"`; + }; + const length = Math.round(hit.overlapLength * 10) / 10; + const from = hit.overlapStart.map((value) => Math.round(value * 10) / 10).join(', '); + const to = hit.overlapEnd.map((value) => Math.round(value * 10) / 10).join(', '); + const message = `[composition/ambiguous-corridor] showcase ${diagramType} ${describe(hit.left)} shares a ${length}px corridor with ${describe(hit.right)} at [${from}] -> [${to}] (segments ${hit.leftSegment} and ${hit.rightSegment}; minimum ${minOverlapPx}px) — ${routeHint}.`; + recordDiagnostic({ + code: 'composition/ambiguous-corridor', + severity: 'error', + message, + subject: relationshipSubject(diagramType, relationCollection, hit.left.relationIndex, hit.left.relation), + evidence: { + otherRelationship: relationshipSubject(diagramType, relationCollection, hit.right.relationIndex, hit.right.relation), + overlapLengthPx: length, + minimumPx: minOverlapPx, + from: hit.overlapStart, + to: hit.overlapEnd, + segmentIndex: hit.leftSegment, + otherSegmentIndex: hit.rightSegment, + }, + supportedFixes: [routeHint], + }); + return message; + }); +} + +// Relationship paths may cross a structural frame, but they must not borrow a +// frame side as a routing corridor. Rounded rectangle corners are trimmed from +// the modeled straight sides so a short corner touch is not mistaken for a +// border run. Any positive straight overlap beyond the numeric epsilon is a +// hard failure in every quality profile; 16px belongs only to the separate, +// neutral short-segment metric and is not a corridor exemption. +export function collectBorderRuns({ routedRelations, frames }) { + const hits = []; + for (const routed of asArray(routedRelations)) { + const routeSegments = Array.isArray(routed?.segments) + ? routed.segments + : asArray(routed?.points).slice(0, -1).map((start, index) => ({ start, end: routed.points[index + 1] })); + if (!routeSegments.length) continue; + if (!routeSegments.every((segment) => ( + Array.isArray(segment?.start) && segment.start.length === 2 && isFinitePoint(...segment.start) + && Array.isArray(segment?.end) && segment.end.length === 2 && isFinitePoint(...segment.end) + ))) continue; + for (const [frameIndex, frame] of asArray(frames).entries()) { + for (const border of frameBorderSegments(frame)) { + const overlaps = []; + for (let segmentIndex = 0; segmentIndex < routeSegments.length; segmentIndex += 1) { + const segment = routeSegments[segmentIndex]; + const overlap = collinearAxisOverlap( + segment.start, + segment.end, + border.start, + border.end, + ); + if (!overlap || overlap.length <= 0.0001) continue; + overlaps.push({ ...overlap, segmentIndex }); + } + if (!overlaps.length) continue; + const merged = mergeBorderOverlaps(overlaps, border); + const longest = [...merged].sort((left, right) => right.length - left.length || left.low - right.low)[0]; + hits.push({ + ...routed, + frame, + frameIndex, + side: border.side, + segmentIndex: Math.min(...overlaps.map((overlap) => overlap.segmentIndex)), + overlapLength: merged.reduce((total, overlap) => total + overlap.length, 0), + overlapStart: longest.start, + overlapEnd: longest.end, + }); + } + } + } + return hits; +} + +export function cleanBorderRunProblems({ + relations, + endpointIds, + frames, + pathFor, + diagramType, + relationCollection, + profile, + routeHint = 'adjust route/via or channel coordinates so the relationship crosses the frame perpendicularly through a clear opening' +}) { + if (!process.env.ARCHIFY_QUALITY_PROFILE && !profile) return []; + const routedRelations = asArray(relations).map((relation, relationIndex) => { + if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null; + if (endpointIds && (!endpointIds.has(relation.from) || !endpointIds.has(relation.to))) return null; + return { relation, relationIndex, points: pathFor(relation)?.points }; + }).filter(Boolean); + return collectBorderRuns({ routedRelations, frames }).map((hit) => { + const relation = hit.relation || {}; + const relationId = relation.id ? ` id "${relation.id}"` : ''; + const frameKind = hit.frame?.kind || hit.frame?.shape || 'frame'; + const frameIdentity = hit.frame?.label || hit.frame?.id || hit.frameIndex; + const length = Math.round(hit.overlapLength * 10) / 10; + const from = hit.overlapStart.map((value) => Math.round(value * 10) / 10).join(', '); + const to = hit.overlapEnd.map((value) => Math.round(value * 10) / 10).join(', '); + const message = `[composition/container-border-run] ${diagramType} ${relationCollection}[${hit.relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" follows ${frameKind} "${frameIdentity}" ${hit.side} border for ${length}px on segment ${hit.segmentIndex} [${from}] -> [${to}] — ${routeHint}.`; + recordDiagnostic({ + code: 'composition/container-border-run', + severity: 'error', + message, + subject: relationshipSubject(diagramType, relationCollection, hit.relationIndex, relation), + evidence: { + frameKind, + frameId: hit.frame?.id, + frameLabel: hit.frame?.label, + side: hit.side, + segmentIndex: hit.segmentIndex, + overlapLengthPx: length, + from: hit.overlapStart, + to: hit.overlapEnd, + }, + supportedFixes: [routeHint], + }); + return message; + }); +} + +export function routeBudgetMetrics({ + routedRelations, + bendsPerRelationship = 2, + stretch = 1.35, + segmentPx = 16, + microSegmentPx = 8, +}) { + let maxBends = 0; + let routesOverSuggestedBends = 0; + let maxStretch = null; + let routesOverSuggestedStretch = 0; + let minSegmentPx = null; + let minInteriorSegmentPx = null; + let shortSegmentCount = 0; + let shortEndpointSegmentCount = 0; + let shortInteriorSegmentCount = 0; + let microSegmentCount = 0; + + for (const routed of asArray(routedRelations)) { + const points = normalizeRoutePoints(routed?.points); + if (points.length < 2) continue; + const bends = Math.max(0, points.length - 2); + maxBends = Math.max(maxBends, bends); + if (bends > bendsPerRelationship) routesOverSuggestedBends += 1; + + let routeLength = 0; + for (let index = 0; index < points.length - 1; index += 1) { + const length = Math.abs(points[index + 1][0] - points[index][0]) + Math.abs(points[index + 1][1] - points[index][1]); + if (length <= 0.0001) continue; + const position = segmentPosition(index, points.length - 1); + routeLength += length; + minSegmentPx = minSegmentPx == null ? length : Math.min(minSegmentPx, length); + if (position === 'interior') { + minInteriorSegmentPx = minInteriorSegmentPx == null ? length : Math.min(minInteriorSegmentPx, length); + } + if (length < segmentPx) { + shortSegmentCount += 1; + if (position === 'interior') shortInteriorSegmentCount += 1; + else shortEndpointSegmentCount += 1; + } + if (length < microSegmentPx) microSegmentCount += 1; + } + const direct = Math.abs(points.at(-1)[0] - points[0][0]) + Math.abs(points.at(-1)[1] - points[0][1]); + if (direct > 0.0001) { + const routeStretch = routeLength / direct; + maxStretch = maxStretch == null ? routeStretch : Math.max(maxStretch, routeStretch); + if (routeStretch > stretch + 0.0001) routesOverSuggestedStretch += 1; + } + } + + return { + maxBends, + routesOverSuggestedBends, + maxStretch, + routesOverSuggestedStretch, + minSegmentPx, + minInteriorSegmentPx, + shortSegmentCount, + shortEndpointSegmentCount, + shortInteriorSegmentCount, + microSegmentCount, + }; +} + +export function collectRouteRhythmIssues({ + routedRelations, + interiorSegmentPx = 16, + microSegmentPx = 8, +}) { + const issues = []; + for (const [fallbackIndex, routed] of asArray(routedRelations).entries()) { + const points = normalizeRoutePoints(routed?.points); + if (points.length < 2) continue; + for (let segmentIndex = 0; segmentIndex < points.length - 1; segmentIndex += 1) { + const start = points[segmentIndex]; + const end = points[segmentIndex + 1]; + const length = Math.abs(end[0] - start[0]) + Math.abs(end[1] - start[1]); + if (length <= 0.0001) continue; + const position = segmentPosition(segmentIndex, points.length - 1); + const code = length < microSegmentPx - 0.0001 + ? 'composition/micro-segment' + : position === 'interior' && length < interiorSegmentPx - 0.0001 + ? 'composition/short-interior-segment' + : null; + if (!code) continue; + issues.push({ + code, + relation: routed.relation, + relationIndex: Number.isInteger(routed.relationIndex) ? routed.relationIndex : fallbackIndex, + segmentIndex, + position, + length, + start, + end, + }); + } + } + return issues; +} + +export function cleanRouteRhythmProblems({ + relations, + endpointIds, + pathFor, + diagramType, + relationCollection, + profile, + routeHint = 'move the channel/via point to remove the cramped turn or give the route more corridor space', + interiorSegmentPx = 16, + microSegmentPx = 8, +}) { + const requestedProfile = process.env.ARCHIFY_QUALITY_PROFILE || profile; + if (requestedProfile !== 'showcase') return []; + const routedRelations = asArray(relations).map((relation, relationIndex) => { + if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null; + if (endpointIds && (!endpointIds.has(relation.from) || !endpointIds.has(relation.to))) return null; + return { relation, relationIndex, points: pathFor(relation)?.points }; + }).filter(Boolean); + return collectRouteRhythmIssues({ routedRelations, interiorSegmentPx, microSegmentPx }).map((hit) => { + const relation = hit.relation || {}; + const relationId = relation.id ? ` id "${relation.id}"` : ''; + const length = Math.round(hit.length * 10) / 10; + const from = hit.start.map((value) => Math.round(value * 10) / 10).join(', '); + const to = hit.end.map((value) => Math.round(value * 10) / 10).join(', '); + const rule = hit.code === 'composition/micro-segment' + ? `is below the ${microSegmentPx}px micro-segment floor` + : `is below the ${interiorSegmentPx}px interior-segment floor`; + const message = `[${hit.code}] showcase ${diagramType} ${relationCollection}[${hit.relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" has a ${length}px ${hit.position} segment ${hit.segmentIndex} [${from}] -> [${to}] that ${rule} — ${routeHint}.`; + recordDiagnostic({ + code: hit.code, + severity: 'error', + message, + subject: relationshipSubject(diagramType, relationCollection, hit.relationIndex, relation), + evidence: { + segmentIndex: hit.segmentIndex, + position: hit.position, + lengthPx: length, + minimumPx: hit.code === 'composition/micro-segment' ? microSegmentPx : interiorSegmentPx, + from: hit.start, + to: hit.end, + }, + supportedFixes: [routeHint], + }); + return message; + }); +} + +export function cleanLabelRouteClearanceProblems({ + relations, + labels, + endpointIds, + pathFor, + diagramType, + relationCollection, + profile, + threshold = 4, + routeHint = 'adjust labelAt, labelDx, labelDy, or labelSegment; otherwise adjust the other relationship route/via/channel', +}) { + const requestedProfile = process.env.ARCHIFY_QUALITY_PROFILE || profile; + if (requestedProfile !== 'showcase') return []; + const routedRelations = asArray(relations).map((relation, relationIndex) => { + if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null; + if (endpointIds && (!endpointIds.has(relation.from) || !endpointIds.has(relation.to))) return null; + return { relation, relationIndex, points: pathFor(relation)?.points }; + }).filter(Boolean); + return collectLabelRouteClearance({ labels, routedRelations, threshold }).map((hit) => { + const describe = (relation, relationIndex) => { + const relationId = relation?.id ? ` id "${relation.id}"` : ''; + const relationLabel = relation?.label ? ` label "${relation.label}"` : ''; + return `${relationCollection}[${relationIndex}]${relationId} "${relation?.from}" -> "${relation?.to}"${relationLabel}`; + }; + const clearance = Math.round(hit.clearance * 10) / 10; + const from = hit.start.map((value) => Math.round(value * 10) / 10).join(', '); + const to = hit.end.map((value) => Math.round(value * 10) / 10).join(', '); + const message = `[composition/label-route-clearance] showcase ${diagramType} label "${hit.label?.label || hit.labelRelation?.label || ''}" on ${describe(hit.labelRelation, hit.labelRelationIndex)} is ${clearance}px from ${describe(hit.otherRelation, hit.otherRelationIndex)} segment ${hit.segmentIndex} [${from}] -> [${to}] (label rect ${formatRect(hit.rect)}; minimum ${threshold}px) — ${routeHint}.`; + recordDiagnostic({ + code: 'composition/label-route-clearance', + severity: 'error', + message, + subject: relationshipSubject(diagramType, relationCollection, hit.labelRelationIndex, hit.labelRelation), + evidence: { + label: hit.label?.label || hit.labelRelation?.label || '', + otherRelationship: relationshipSubject(diagramType, relationCollection, hit.otherRelationIndex, hit.otherRelation), + segmentIndex: hit.segmentIndex, + clearancePx: clearance, + minimumPx: threshold, + labelRect: hit.rect, + from: hit.start, + to: hit.end, + }, + supportedFixes: [routeHint], + }); + return message; + }); +} + +function segmentPosition(index, segmentCount) { + if (index === 0) return 'source-stub'; + if (index === segmentCount - 1) return 'target-stub'; + return 'interior'; +} + +export function normalizeRoutePoints(points) { + const finite = asArray(points).filter((point) => Array.isArray(point) && point.length === 2 && isFinitePoint(...point)); + const deduped = []; + for (const point of finite) { + const previous = deduped.at(-1); + if (!previous || Math.abs(point[0] - previous[0]) > 0.0001 || Math.abs(point[1] - previous[1]) > 0.0001) deduped.push(point); + } + const normalized = []; + for (const point of deduped) { + while (normalized.length >= 2 && collinearForward(normalized.at(-2), normalized.at(-1), point)) normalized.pop(); + normalized.push(point); + } + return normalized; +} + +function pointRectDistance(point, rect) { + const dx = Math.max(rect.x - point[0], 0, point[0] - (rect.x + rect.width)); + const dy = Math.max(rect.y - point[1], 0, point[1] - (rect.y + rect.height)); + return Math.hypot(dx, dy); +} + +function pointSegmentDistance(point, start, end) { + const dx = end[0] - start[0]; + const dy = end[1] - start[1]; + const lengthSquared = dx * dx + dy * dy; + if (lengthSquared <= 0.0000001) return Math.hypot(point[0] - start[0], point[1] - start[1]); + const projection = Math.max(0, Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / lengthSquared)); + return Math.hypot(point[0] - (start[0] + projection * dx), point[1] - (start[1] + projection * dy)); +} + +function collinearForward(a, b, c) { + if (Math.abs(crossProduct(a, b, c)) > 0.0001) return false; + return (b[0] - a[0]) * (c[0] - b[0]) + (b[1] - a[1]) * (c[1] - b[1]) >= -0.0001; +} + +function frameBorderSegments(frame) { + if (!frame || typeof frame !== 'object') return []; + if (frame.shape === 'line') { + const start = frame.start || [frame.x1, frame.y1]; + const end = frame.end || [frame.x2, frame.y2]; + return isFinitePoint(...start, ...end) ? [{ side: 'line', start, end }] : []; + } + if (!isFinitePoint(frame.x, frame.y, frame.width, frame.height) || frame.width <= 0 || frame.height <= 0) return []; + const radius = Math.max(0, Math.min(Number(frame.radius) || 0, frame.width / 2, frame.height / 2)); + const left = frame.x; + const right = frame.x + frame.width; + const top = frame.y; + const bottom = frame.y + frame.height; + return [ + { side: 'top', start: [left + radius, top], end: [right - radius, top] }, + { side: 'right', start: [right, top + radius], end: [right, bottom - radius] }, + { side: 'bottom', start: [right - radius, bottom], end: [left + radius, bottom] }, + { side: 'left', start: [left, bottom - radius], end: [left, top + radius] }, + ].filter(({ start, end }) => Math.hypot(end[0] - start[0], end[1] - start[1]) > 0.0001); +} + +function mergeBorderOverlaps(overlaps, border) { + const horizontal = Math.abs(border.start[1] - border.end[1]) <= 0.0001; + const axis = horizontal ? 0 : 1; + const fixed = horizontal ? border.start[1] : border.start[0]; + const sorted = overlaps.map((overlap) => ({ + low: Math.min(overlap.start[axis], overlap.end[axis]), + high: Math.max(overlap.start[axis], overlap.end[axis]), + })).sort((left, right) => left.low - right.low || left.high - right.high); + const merged = []; + for (const interval of sorted) { + const previous = merged.at(-1); + if (previous && interval.low <= previous.high + 0.0001) previous.high = Math.max(previous.high, interval.high); + else merged.push({ ...interval }); + } + return merged.map((interval) => ({ + ...interval, + length: interval.high - interval.low, + start: horizontal ? [interval.low, fixed] : [fixed, interval.low], + end: horizontal ? [interval.high, fixed] : [fixed, interval.high], + })); +} + +function collinearAxisOverlap(a, b, c, d) { + const epsilon = 0.0001; + const horizontal = Math.abs(a[1] - b[1]) <= epsilon + && Math.abs(c[1] - d[1]) <= epsilon + && Math.abs(a[1] - c[1]) <= epsilon; + const vertical = Math.abs(a[0] - b[0]) <= epsilon + && Math.abs(c[0] - d[0]) <= epsilon + && Math.abs(a[0] - c[0]) <= epsilon; + if (!horizontal && !vertical) return null; + const axis = horizontal ? 0 : 1; + const low = Math.max(Math.min(a[axis], b[axis]), Math.min(c[axis], d[axis])); + const high = Math.min(Math.max(a[axis], b[axis]), Math.max(c[axis], d[axis])); + if (high - low <= epsilon) return null; + const fixed = horizontal ? a[1] : a[0]; + return { + length: high - low, + start: horizontal ? [low, fixed] : [fixed, low], + end: horizontal ? [high, fixed] : [fixed, high], + }; +} + +function properSegmentIntersection(a, b, c, d) { + const abC = crossProduct(a, b, c); + const abD = crossProduct(a, b, d); + const cdA = crossProduct(c, d, a); + const cdB = crossProduct(c, d, b); + const epsilon = 0.0001; + const opposite = (left, right) => (left > epsilon && right < -epsilon) || (left < -epsilon && right > epsilon); + if (!opposite(abC, abD) || !opposite(cdA, cdB)) return null; + + const denominator = (a[0] - b[0]) * (c[1] - d[1]) - (a[1] - b[1]) * (c[0] - d[0]); + if (Math.abs(denominator) < epsilon) return null; + const ab = a[0] * b[1] - a[1] * b[0]; + const cd = c[0] * d[1] - c[1] * d[0]; + return [ + (ab * (c[0] - d[0]) - (a[0] - b[0]) * cd) / denominator, + (ab * (c[1] - d[1]) - (a[1] - b[1]) * cd) / denominator + ]; +} + +function crossProduct(a, b, c) { + return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); +} + +function pointInBox(point, box) { + return point[0] >= box.x1 && point[0] <= box.x2 && point[1] >= box.y1 && point[1] <= box.y2; +} + +function segmentsIntersect(a, b, c, d) { + const o1 = orientation(a, b, c); + const o2 = orientation(a, b, d); + const o3 = orientation(c, d, a); + const o4 = orientation(c, d, b); + + if (o1 === 0 && onSegment(a, c, b)) return true; + if (o2 === 0 && onSegment(a, d, b)) return true; + if (o3 === 0 && onSegment(c, a, d)) return true; + if (o4 === 0 && onSegment(c, b, d)) return true; + + return o1 !== o2 && o3 !== o4; +} + +function orientation(a, b, c) { + const value = (b[1] - a[1]) * (c[0] - b[0]) - (b[0] - a[0]) * (c[1] - b[1]); + if (Math.abs(value) < 0.0001) return 0; + return value > 0 ? 1 : 2; +} + +function onSegment(a, b, c) { + return ( + b[0] <= Math.max(a[0], c[0]) && + b[0] >= Math.min(a[0], c[0]) && + b[1] <= Math.max(a[1], c[1]) && + b[1] >= Math.min(a[1], c[1]) + ); +} + +export function anchor(rect, side) { + switch (side) { + case 'left': return [rect.x, rect.cy]; + case 'right': return [rect.x + rect.width, rect.cy]; + case 'top': return [rect.cx, rect.y]; + case 'bottom': return [rect.cx, rect.y + rect.height]; + default: + return [rect.x + rect.width, rect.cy]; + } +} + +const PORT_OUTWARD_VECTOR = { + left: [-1, 0], + right: [1, 0], + top: [0, -1], + bottom: [0, 1], +}; + +// Automatic port spreading can put otherwise parallel anchors only a few +// pixels apart. A conventional midpoint dogleg then violates the renderer's +// own 8px/16px route-rhythm floors. Return a full outside-channel route when +// that happens, or null when the normal automatic route remains appropriate. +export function automaticPortRhythmBridge( + start, + end, + fromSide, + toSide, + { endpointStubPx = 24, interiorSegmentPx = 16, accept } = {}, +) { + if (!Array.isArray(start) || !Array.isArray(end) + || start.length !== 2 || end.length !== 2 + || !isFinitePoint(...start, ...end)) return null; + const fromVector = PORT_OUTWARD_VECTOR[fromSide]; + const toVector = PORT_OUTWARD_VECTOR[toSide]; + if (!fromVector || !toVector) return null; + + const startStub = [ + start[0] + fromVector[0] * endpointStubPx, + start[1] + fromVector[1] * endpointStubPx, + ]; + const endStub = [ + end[0] + toVector[0] * endpointStubPx, + end[1] + toVector[1] * endpointStubPx, + ]; + const candidates = []; + const verticalSides = new Set(['top', 'bottom']); + const horizontalSides = new Set(['left', 'right']); + + if (verticalSides.has(fromSide) && verticalSides.has(toSide) + && Math.abs(start[0] - end[0]) < interiorSegmentPx) { + for (const channelX of [ + Math.max(start[0], end[0]) + interiorSegmentPx, + Math.min(start[0], end[0]) - interiorSegmentPx, + ]) { + candidates.push([ + start, + startStub, + [channelX, startStub[1]], + [channelX, endStub[1]], + endStub, + end, + ]); + } + } + if (horizontalSides.has(fromSide) && horizontalSides.has(toSide) + && Math.abs(start[1] - end[1]) < interiorSegmentPx) { + for (const channelY of [ + Math.max(start[1], end[1]) + interiorSegmentPx, + Math.min(start[1], end[1]) - interiorSegmentPx, + ]) { + candidates.push([ + start, + startStub, + [startStub[0], channelY], + [endStub[0], channelY], + endStub, + end, + ]); + } + } + + return candidates + .map((points) => normalizeRoutePoints(points)) + .find((points) => ( + routeHonorsEndpointSides(points, fromSide, toSide) + && collectRouteRhythmIssues({ routedRelations: [{ points }], interiorSegmentPx }).length === 0 + && (typeof accept !== 'function' || accept(points)) + )) || null; +} + +// Keep conservative auto-routed fan-out/fan-in relationships visually +// distinct without changing authored route controls. The returned map only +// contains endpoints that belong to a shared automatic midpoint anchor. +export function automaticPortSpread(relations, boxes, { gutter = 16, maxSpacing = 14, sideFor } = {}) { + const groups = new Map(); + const spread = new Map(); + + const add = (relation, endpoint, rect, side, counterpart) => { + const key = `${rect.id}\u0000${side}`; + const items = groups.get(key) || []; + items.push({ relation, endpoint, rect, side, counterpart }); + groups.set(key, items); + }; + + for (const relation of asArray(relations)) { + if (!relation || (relation.route && relation.route !== 'auto')) continue; + if (relation.via || relation.channelX !== undefined || relation.channelY !== undefined || relation.labelAt) continue; + const from = boxes.get(relation.from); + const to = boxes.get(relation.to); + if (!from || !to) continue; + const fromSide = chosenSide( + relation.fromSide, + sideFor?.(relation, 'source') || defaultFromSide(from, to), + ); + const toSide = chosenSide( + relation.toSide, + sideFor?.(relation, 'target') || defaultToSide(from, to), + ); + add(relation, 'from', from, fromSide, to); + add(relation, 'to', to, toSide, from); + } + + for (const items of groups.values()) { + if (items.length < 2) continue; + const verticalSide = items[0].side === 'left' || items[0].side === 'right'; + items.sort((a, b) => { + const aCoordinate = verticalSide ? a.counterpart.cy : a.counterpart.cx; + const bCoordinate = verticalSide ? b.counterpart.cy : b.counterpart.cx; + if (aCoordinate !== bCoordinate) return aCoordinate - bCoordinate; + const aKey = `${a.relation.id || ''}\u0000${a.relation.from}\u0000${a.relation.to}\u0000${a.relation.label || ''}`; + const bKey = `${b.relation.id || ''}\u0000${b.relation.from}\u0000${b.relation.to}\u0000${b.relation.label || ''}`; + return aKey < bKey ? -1 : aKey > bKey ? 1 : 0; + }); + + const extent = verticalSide ? items[0].rect.height : items[0].rect.width; + const usable = Math.max(0, extent - gutter * 2); + const spacing = Math.min(maxSpacing, usable / (items.length - 1)); + if (!(spacing > 0)) continue; + + for (const [index, item] of items.entries()) { + const offset = (index - (items.length - 1) / 2) * spacing; + const point = anchor(item.rect, item.side); + if (verticalSide) point[1] += offset; + else point[0] += offset; + const endpoints = spread.get(item.relation) || {}; + endpoints[item.endpoint] = point; + spread.set(item.relation, endpoints); + } + } + + return spread; +} + +export function defaultFromSide(from, to) { + if (to.cx < from.cx) return 'left'; + if (to.cx > from.cx) return 'right'; + if (to.cy > from.cy) return 'bottom'; + return 'top'; +} + +export function defaultToSide(from, to) { + if (to.cx < from.cx) return 'right'; + if (to.cx > from.cx) return 'left'; + if (to.cy > from.cy) return 'top'; + return 'bottom'; +} + +export function chosenSide(side, fallback) { + return side && side !== 'auto' ? side : fallback; +} + +export function polylinePath(points) { + return points.map(([x, y], index) => `${index === 0 ? 'M' : 'L'} ${x} ${y}`).join(' '); +} + +export function routePointsValue(points) { + return asArray(points) + .filter((point) => Array.isArray(point) && point.length === 2 && isFinitePoint(...point)) + .map(([x, y]) => `${x},${y}`) + .join(';'); +} + +export function roundedPath(points, radius) { + if (points.length < 3 || radius <= 0) { + return polylinePath(points); + } + + const commands = [`M ${points[0][0]} ${points[0][1]}`]; + for (let i = 1; i < points.length - 1; i += 1) { + const [px, py] = points[i - 1]; + const [cx, cy] = points[i]; + const [nx, ny] = points[i + 1]; + const prevLen = Math.hypot(cx - px, cy - py); + const nextLen = Math.hypot(nx - cx, ny - cy); + const r = Math.min(radius, prevLen / 2, nextLen / 2); + if (r < 1) { + commands.push(`L ${cx} ${cy}`); + continue; + } + const before = [cx - ((cx - px) / prevLen) * r, cy - ((cy - py) / prevLen) * r]; + const after = [cx + ((nx - cx) / nextLen) * r, cy + ((ny - cy) / nextLen) * r]; + commands.push(`L ${before[0]} ${before[1]}`); + commands.push(`Q ${cx} ${cy} ${after[0]} ${after[1]}`); + } + const [endX, endY] = points[points.length - 1]; + commands.push(`L ${endX} ${endY}`); + return commands.join(' '); +} + +// Shared by edges/flows/transitions: all carry the same optional +// labelAt/labelDx/labelDy/labelSegment knobs. +export function labelPoint(item, points) { + if (item.labelAt) return item.labelAt; + if (points.length === 2) { + return [ + (points[0][0] + points[1][0]) / 2 + (item.labelDx || 0), + points[0][1] - 10 + (item.labelDy || 0) + ]; + } + const segmentIndex = Math.min(points.length - 2, Math.max(0, item.labelSegment ?? 1)); + const a = points[segmentIndex]; + const b = points[segmentIndex + 1]; + return [(a[0] + b[0]) / 2 + (item.labelDx || 0), (a[1] + b[1]) / 2 - 10 + (item.labelDy || 0)]; +} + +export const componentFill = { + frontend: 'c-frontend', + backend: 'c-backend', + database: 'c-database', + cloud: 'c-cloud', + security: 'c-security', + messagebus: 'c-messagebus', + external: 'c-external' +}; + +export const componentText = { + frontend: 't-frontend', + backend: 't-backend', + database: 't-database', + cloud: 't-cloud', + security: 't-security', + messagebus: 't-messagebus', + external: 't-external' +}; + +export const arrowClassMap = { + default: ['a-default', 'arrowhead'], + emphasis: ['a-emphasis', 'arrowhead-emphasis'], + security: ['a-security', 'arrowhead-security'], + dashed: ['a-dashed', 'arrowhead-dashed'] +}; + +// Label accent per edge variant. Workflow colors dashed (async trace) labels +// like the trace store it points at; the other renderers use the bus color. +export function variantAccent(variant, { dashed = 't-messagebus' } = {}) { + return variant === 'security' + ? 't-security' + : variant === 'emphasis' + ? 't-backend' + : variant === 'dashed' + ? dashed + : 't-muted'; +} + +export function formatRect(r) { + return `[${Math.round(r.x)}, ${Math.round(r.y)}, ${Math.round(r.width)}, ${Math.round(r.height)}]`; +} + +function formatDelta(n) { + const v = Math.round(n); + return v >= 0 ? `+${v}` : String(v); +} + +/** Actionable hint when an edge label rect hits a node/component box (#7). */ +export function suggestLabelObstacleFix(labelRect, lx, ly, obstacle, obstacleKind = 'component') { + const lxR = Math.round(lx); + const lyR = Math.round(ly); + const belowY = Math.round(obstacle.y + obstacle.height + 14); + const aboveY = Math.round(obstacle.y - 4); + return [ + ` label rect: ${formatRect(labelRect)}`, + ` ${obstacleKind} "${obstacle.id}" rect: ${formatRect(obstacle)}`, + ` Suggested fix: labelAt [${lxR}, ${belowY}] or labelDy ${formatDelta(belowY - lyR)} (below); or labelAt [${lxR}, ${aboveY}] or labelDy ${formatDelta(aboveY - lyR)} (above)`, + ].join('\n'); +} + +/** Hint when two edge labels collide. */ +export function suggestLabelPairFix(a, b) { + return [ + ` "${a.label}" ${formatRect(a)}; "${b.label}" ${formatRect(b)}`, + ' Suggested fix: add labelDy +24 on one edge, adjust labelDx, or remove one label', + ].join('\n'); +} + +/** Hint when two components/nodes are too close. */ +export function suggestComponentSeparation(a, b, minGap = 8) { + const rightX = Math.round(a.x + a.width + minGap); + const belowY = Math.round(a.y + a.height + minGap); + return [ + ` "${a.id}" ${formatRect(a)}; "${b.id}" ${formatRect(b)}`, + ` Suggested fix: move "${b.id}" pos to [${rightX}, ${Math.round(b.y)}] (right of "${a.id}") or [${Math.round(b.x)}, ${belowY}] (below)`, + ].join('\n'); +} diff --git a/visualization/archify-core/renderers/shared/i18n.mjs b/visualization/archify-core/renderers/shared/i18n.mjs new file mode 100644 index 0000000..5724bca --- /dev/null +++ b/visualization/archify-core/renderers/shared/i18n.mjs @@ -0,0 +1,358 @@ +export const SUPPORTED_LOCALES = ['en', 'zh-CN']; +export const DEFAULT_LOCALE = 'en'; + +const ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + +export function escapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, (character) => ESCAPE_MAP[character]); +} + +// One catalog feeds renderer-time SVG/HTML copy and the selected runtime +// catalog embedded in each standalone artifact. Keeping every locale in one +// tuple makes missing translations impossible to hide behind an English +// fallback during development. +const MESSAGE_PAIRS = { + 'page.title': ['{title} Diagram', '{title}'], + 'diagram.description.architecture': ['An architecture diagram generated by Archify.', '由 Archify 生成的架构图。'], + 'diagram.description.workflow': ['A workflow diagram generated by Archify.', '由 Archify 生成的工作流图。'], + 'diagram.description.sequence': ['A sequence diagram generated by Archify.', '由 Archify 生成的时序图。'], + 'diagram.description.dataflow': ['A data-flow diagram generated by Archify.', '由 Archify 生成的数据流图。'], + 'diagram.description.lifecycle': ['A lifecycle diagram generated by Archify.', '由 Archify 生成的生命周期图。'], + 'node.focus': ['Focus {label}', '聚焦{label}'], + 'node.focus.detail': ['Focus {label}, {detail}', '聚焦{label},{detail}'], + 'node.context.architecture': ['Architecture component', '架构组件'], + 'node.context.workflow': ['Workflow node', '工作流节点'], + 'node.context.sequence': ['Sequence participant', '时序参与者'], + 'node.context.dataflow': ['Data-flow node', '数据流节点'], + 'node.context.lifecycle': ['Lifecycle state', '生命周期状态'], + + 'viewer.kind.frontend': ['Frontend', '前端'], + 'viewer.kind.backend': ['Backend', '后端'], + 'viewer.kind.database': ['Database', '数据库'], + 'viewer.kind.cloud': ['Cloud', '云服务'], + 'viewer.kind.security': ['Security', '安全'], + 'viewer.kind.messagebus': ['Message bus', '消息总线'], + 'viewer.kind.external': ['External', '外部系统'], + 'viewer.kind.entrypoint': ['Entrypoint', '入口'], + 'viewer.kind.owner': ['Owner', '所有者'], + 'viewer.kind.candidate': ['Candidate', '候选'], + 'viewer.kind.consumer': ['Consumer', '消费者'], + 'viewer.kind.state': ['State', '状态'], + 'viewer.kind.store': ['Store', '存储'], + 'viewer.kind.boundary': ['Boundary', '边界'], + 'viewer.kind.neutral': ['Neutral', '中性'], + 'viewer.kind.node': ['Node', '节点'], + 'viewer.kind.start': ['Start', '开始'], + 'viewer.kind.active': ['Active', '活动'], + 'viewer.kind.waiting': ['Waiting', '等待'], + 'viewer.kind.decision': ['Decision', '决策'], + 'viewer.kind.success': ['Success', '成功'], + 'viewer.kind.failure': ['Failure', '失败'], + + 'viewer.toolbar.actions': ['Diagram actions', '图表操作'], + 'viewer.theme.toggle.title': ['Toggle theme (T)', '切换主题(T)'], + 'viewer.theme.toggle': ['Toggle color theme', '切换颜色主题'], + 'viewer.theme.dark': ['Dark', '深色'], + 'viewer.theme.light': ['Light', '浅色'], + + 'viewer.export.button': ['Export', '导出'], + 'viewer.export.button.title': ['Export diagram (E)', '导出图表(E)'], + 'viewer.export.diagram': ['Export diagram', '导出图表'], + 'viewer.export.menu': ['Export', '导出'], + 'viewer.export.subtitle': ['Portable, clean outputs', '便携、整洁的输出'], + 'viewer.export.share': ['Share', '分享'], + 'viewer.export.shareCard': ['Share Card', '分享卡片'], + 'viewer.export.routeShareCard': ['Route Share Card', '路径分享卡片'], + 'viewer.export.reachShareCard': ['Reach Share Card', '可达范围分享卡片'], + 'viewer.export.copyShareCard': ['Copy Share Card', '复制分享卡片'], + 'viewer.export.copyDiagram': ['Copy diagram', '复制图表'], + 'viewer.export.clipboardPng': ['PNG to clipboard', '复制 PNG 到剪贴板'], + 'viewer.export.raster': ['Raster images', '位图'], + 'viewer.export.image': ['Image', '图像'], + 'viewer.export.lossless': ['Lossless image', '无损图像'], + 'viewer.export.compact': ['Compact image', '紧凑图像'], + 'viewer.export.modern': ['Modern image', '现代图像格式'], + 'viewer.export.vector': ['Vector image', '矢量图像'], + 'viewer.export.vector.heading': ['Vector', '矢量'], + 'viewer.export.editable': ['Editable vector', '可编辑矢量图'], + 'viewer.export.unsupported': ['Not supported by this browser', '当前浏览器不支持'], + 'viewer.export.clipboardUnsupported': ['Clipboard image write not supported by this browser', '当前浏览器不支持写入图片剪贴板'], + 'viewer.export.clipboardUnsupported.period': ['Clipboard image write not supported by this browser.', '当前浏览器不支持写入图片剪贴板。'], + 'viewer.export.clipboardUnsupported.short': ['Clipboard image write not supported in this browser.', '此浏览器不支持写入图片剪贴板。'], + 'viewer.export.failed': ['Export failed: {message}', '导出失败:{message}'], + 'viewer.export.unknownVariant': ['Unknown Share Card variant: {variant}', '未知的分享卡片类型:{variant}'], + 'viewer.export.routeRequired': ['Trace a route before exporting a Route Share Card', '请先追踪路径,再导出路径分享卡片'], + 'viewer.export.reachRequired': ['Trace authored reach before exporting a Reach Share Card', '请先追踪编写可达范围,再导出可达范围分享卡片'], + 'viewer.export.unknown': ['unknown', '未知错误'], + 'viewer.export.routeFailed': ['Route Share Card export failed: {message}', '路径分享卡片导出失败:{message}'], + 'viewer.export.reachFailed': ['Reach Share Card export failed: {message}', '可达范围分享卡片导出失败:{message}'], + 'viewer.export.copyFailed': ['Copy failed: {message}', '复制失败:{message}'], + 'viewer.export.copiedPng': ['Copied PNG to clipboard', '已将 PNG 复制到剪贴板'], + 'viewer.export.copiedShare': ['Copied Share Card', '已复制分享卡片'], + 'viewer.export.downloadedShare': ['Downloaded Share Card', '已下载分享卡片'], + 'viewer.export.downloadedRoute': ['Downloaded Route Share Card', '已下载路径分享卡片'], + 'viewer.export.downloadedReach': ['Downloaded Reach Share Card', '已下载可达范围分享卡片'], + 'viewer.export.card.routeSummary.one': ['Route: {source} → {target} · {count} directed hop', '路径:{source} → {target} · {count} 个有向跳转'], + 'viewer.export.card.routeSummary.other': ['Route: {source} → {target} · {count} directed hops', '路径:{source} → {target} · {count} 个有向跳转'], + 'viewer.export.card.reachSummary': ['Authored {direction} from {origin} · {nodes} · {links} · max {hops}', '从{origin}开始的编写{direction} · {nodes} · {links} · 最深 {hops}'], + 'viewer.export.card.node.one': ['{count} node', '{count} 个节点'], + 'viewer.export.card.node.other': ['{count} nodes', '{count} 个节点'], + 'viewer.export.card.link.one': ['{count} link', '{count} 条连接'], + 'viewer.export.card.link.other': ['{count} links', '{count} 条连接'], + 'viewer.export.card.hop.one': ['{count} hop', '{count} 跳'], + 'viewer.export.card.hop.other': ['{count} hops', '{count} 跳'], + 'viewer.export.card.routeBadge': ['ARCHIFY · ROUTE · {hops}', 'ARCHIFY · 路径 · {hops}'], + 'viewer.export.card.reachBadge': ['ARCHIFY · {direction} REACH', 'ARCHIFY · {direction}可达范围'], + 'viewer.export.card.defaultBadge': ['ARCHIFY · SIGNAL FLOW · {theme}', 'ARCHIFY · 信号流 · {theme}'], + 'viewer.export.direction.upstream': ['Upstream', '上游'], + 'viewer.export.direction.downstream': ['Downstream', '下游'], + 'viewer.export.error.canvasUnavailable': ['Canvas unavailable for {label}', '无法为{label}使用画布'], + 'viewer.export.error.contextUnavailable': ['2D canvas context unavailable for {label}', '无法为{label}创建二维画布上下文'], + 'viewer.export.error.toBlobUnavailable': ['canvas.toBlob unavailable for {label}', '{label}无法使用 canvas.toBlob'], + 'viewer.export.error.toBlobNull': ['canvas.toBlob returned no data for {label}', '{label}的 canvas.toBlob 未返回数据'], + 'viewer.export.error.variantsCombined': ['Share Card variants cannot be combined', '无法同时组合多种分享卡片类型'], + 'viewer.export.error.viewerState': ['Share Card export could not remove temporary viewer state', '分享卡片导出无法移除临时 Viewer 状态'], + 'viewer.export.error.routeState': ['Route Card export could not preserve the resolved route safely', '路径卡片导出无法安全保留已解析路径'], + 'viewer.export.error.reachState': ['Reach Card export could not preserve authored reach safely', '可达范围卡片导出无法安全保留编写的可达范围'], + + + 'viewer.finder.title': ['Find a node', '查找节点'], + 'viewer.finder.close': ['Close node finder', '关闭节点查找器'], + 'viewer.finder.placeholder': ['Search labels or IDs', '搜索标签或 ID'], + 'viewer.finder.search': ['Search diagram nodes', '搜索图表节点'], + 'viewer.finder.results': ['Diagram nodes', '图表节点'], + 'viewer.finder.empty': ['No matching nodes', '没有匹配的节点'], + 'viewer.finder.result.focus': ['Focus {label}', '聚焦{label}'], + 'viewer.finder.result.routeStart': ['Choose {label} as route start', '选择{label}作为路径起点'], + 'viewer.finder.result.routeTarget': ['Choose {label} as route destination, {links}', '选择{label}作为路径终点,{links}'], + 'viewer.finder.status.empty': ['No matching nodes', '没有匹配的节点'], + 'viewer.finder.status.count.one': ['{count} matching node', '{count} 个匹配节点'], + 'viewer.finder.status.count.other': ['{count} matching nodes', '{count} 个匹配节点'], + 'viewer.finder.noun.nodes': ['nodes', '个节点'], + 'viewer.finder.link.one': ['{count} link', '{count} 条连接'], + 'viewer.finder.link.other': ['{count} links', '{count} 条连接'], + 'viewer.finder.result.focus.one': ['Focus {label}, {count} related connection', '聚焦{label},{count} 条相关连接'], + 'viewer.finder.result.focus.other': ['Focus {label}, {count} related connections', '聚焦{label},{count} 条相关连接'], + 'viewer.finder.status.filtered': ['{visible} of {available} {noun}', '{visible}/{available} {noun}'], + 'viewer.finder.status.all': ['{available} {noun}', '{available} {noun}'], + 'viewer.focus.selectedNodes': ['{count} selected nodes', '已选择 {count} 个节点'], + + 'viewer.passport.eyebrow': ['Semantic passport', '语义护照'], + 'viewer.passport.metadata': ['Node metadata', '节点元数据'], + 'viewer.passport.reach': ['Authored reach', '编写可达范围'], + 'viewer.passport.reach.trace': ['Trace authored reachability', '追踪编写的可达性'], + 'viewer.passport.upstream': ['Upstream', '上游'], + 'viewer.passport.downstream': ['Downstream', '下游'], + 'viewer.passport.upstream.trace': ['Trace upstream authored reachability', '追踪上游编写可达性'], + 'viewer.passport.downstream.trace': ['Trace downstream authored reachability', '追踪下游编写可达性'], + 'viewer.passport.close': ['Close semantic passport', '关闭语义护照'], + 'viewer.passport.copy': ['Copy link', '复制链接'], + 'viewer.passport.copy.focus': ['Copy link to focused node', '复制聚焦节点的链接'], + 'viewer.passport.relations': ['Relations', '关系'], + 'viewer.passport.relations.show': ['Show connected relationships', '显示关联关系'], + 'viewer.passport.relations.hide': ['Hide connected relationships', '隐藏关联关系'], + 'viewer.passport.relations.list': ['Connected relationships', '关联关系'], + 'viewer.passport.copyRelation': ['Copy relation', '复制关系'], + 'viewer.passport.copyNode': ['Copy node', '复制节点'], + 'viewer.passport.copyPinned': ['Copy link to pinned relationship', '复制固定关系的链接'], + 'viewer.passport.copySource': ['Copy link to source node', '复制来源节点的链接'], + 'viewer.passport.copy.focused.success': ['Focused node link copied', '已复制聚焦节点链接'], + 'viewer.passport.copy.pinned.success': ['Pinned relationship link copied', '已复制固定关系链接'], + 'viewer.passport.copy.focused.failed': ['Could not copy focused node link', '无法复制聚焦节点链接'], + 'viewer.passport.copy.pinned.failed': ['Could not copy pinned relationship link', '无法复制固定关系链接'], + 'viewer.passport.relationship.none': ['No connected relationships', '没有关联关系'], + 'viewer.passport.relationship.count.one': ['{count} relation', '{count} 条关系'], + 'viewer.passport.relationship.count.other': ['{count} relations', '{count} 条关系'], + 'viewer.passport.relationship.show.one': ['Show {count} connected relationship', '显示 {count} 条关联关系'], + 'viewer.passport.relationship.show.other': ['Show {count} connected relationships', '显示 {count} 条关联关系'], + 'viewer.passport.relationship.summary': ['{out} outgoing · {in} incoming{loops}', '{out} 条出向 · {in} 条入向{loops}'], + 'viewer.passport.relationship.loops': [' · {count} loop', ' · {count} 条自环'], + 'viewer.passport.relationship.explorer': ['Direct relationship explorer', '直接关系浏览器'], + 'viewer.passport.relationship.help': ['Use arrow keys to explore relationships. Press Enter or Space to pin details; Escape clears.', '使用方向键浏览关系。按 Enter 或空格键固定详情;按 Escape 清除。'], + 'viewer.passport.relationship.loopsBack': ['loops back', '回环'], + 'viewer.passport.relationship.connectsTo': ['connects to', '连接到'], + 'viewer.passport.relationship.connectsFrom': ['connects from', '连接自'], + 'viewer.passport.relationship.pinned': ['Pinned relationship · {from} → {to} · {label}', '已固定关系 · {from} → {to} · {label}'], + 'viewer.passport.relationship.inspect': ['Inspect relationship {index} of {total}: {from} to {to}, {label}. Press Enter for details.', '检查第 {index}/{total} 条关系:{from} 到 {to},{label}。按 Enter 查看详情。'], + 'viewer.passport.relationship.group.out': ['Outgoing', '出向'], + 'viewer.passport.relationship.group.in': ['Incoming', '入向'], + 'viewer.passport.relationship.group.loop': ['Self loops', '自环'], + 'viewer.passport.relationship.row': ['{group}: {relationship}, {neighbor}', '{group}:{relationship},{neighbor}'], + 'viewer.passport.relationship.direction.out': ['OUT →', '出 →'], + 'viewer.passport.relationship.direction.in': ['← IN', '← 入'], + 'viewer.passport.relationship.direction.loop': ['LOOP', '自环'], + 'viewer.passport.reach.upstream.one': ['Trace {count} upstream authored node', '追踪 {count} 个上游编写节点'], + 'viewer.passport.reach.upstream.other': ['Trace {count} upstream authored nodes', '追踪 {count} 个上游编写节点'], + 'viewer.passport.reach.downstream.one': ['Trace {count} downstream authored node', '追踪 {count} 个下游编写节点'], + 'viewer.passport.reach.downstream.other': ['Trace {count} downstream authored nodes', '追踪 {count} 个下游编写节点'], + 'viewer.passport.reach.noUpstream': ['No upstream authored nodes', '没有上游编写节点'], + 'viewer.passport.reach.noDownstream': ['No downstream authored nodes', '没有下游编写节点'], + 'viewer.passport.reach.status': ['{direction} · {nodes} nodes · {links} links · max {hops} hops', '{direction} · {nodes} 个节点 · {links} 条连接 · 最深 {hops} 跳'], + + 'viewer.route.eyebrow': ['Route probe', '路径探测'], + 'viewer.route.start': ['Choose a start node', '选择起点节点'], + 'viewer.route.start.find': ['Find start', '查找起点'], + 'viewer.route.start.find.aria': ['Find a route start', '查找路径起点'], + 'viewer.route.copy': ['Copy link', '复制链接'], + 'viewer.route.copy.aria': ['Copy link to traced route', '复制已追踪路径的链接'], + 'viewer.route.clear': ['Clear', '清除'], + 'viewer.route.clear.aria': ['Clear route probe', '清除路径探测'], + 'viewer.route.traced': ['Traced route', '已追踪路径'], + 'viewer.route.pickTwo': ['Pick two semantic nodes on the diagram', '在图表中选择两个语义节点'], + 'viewer.route.pickOne': ['Pick a semantic node on the diagram', '在图表中选择一个语义节点'], + 'viewer.route.controls': ['Route journey controls', '路径旅程控制'], + 'viewer.route.previous': ['Previous route position', '上一个路径位置'], + 'viewer.route.play': ['Play route journey', '播放路径旅程'], + 'viewer.route.pause': ['Pause route journey', '暂停路径旅程'], + 'viewer.route.replay': ['Replay route journey', '重播路径旅程'], + 'viewer.route.next': ['Next route position', '下一个路径位置'], + 'viewer.route.journey': ['Journey', '旅程'], + 'viewer.route.pause.label': ['Pause', '暂停'], + 'viewer.route.replay.label': ['Replay', '重播'], + 'viewer.route.overview': ['Overview', '总览'], + 'viewer.route.overview.aria': ['Show complete route overview', '显示完整路径总览'], + 'viewer.route.instructions': ['Choose the source, then the destination. Direction matters.', '先选择来源,再选择目标;方向很重要。'], + 'viewer.route.destination': ['Choose a destination from {label}', '选择从{label}出发的目标'], + 'viewer.route.destination.find': ['Find target', '查找目标'], + 'viewer.route.destination.find.aria': ['Find a reachable route destination', '查找可达的路径目标'], + 'viewer.route.differentDestination': ['Choose a different destination', '选择其他目标'], + 'viewer.route.distinct': ['A route needs two distinct semantic nodes.', '一条路径需要两个不同的语义节点。'], + 'viewer.route.unreachable': ['No directed route to {label}', '没有通往{label}的有向路径'], + 'viewer.route.unreachable.detail': ['{target} is not reachable from {source}. Pick a highlighted destination.', '从{source}无法到达{target}。请选择高亮的目标。'], + 'viewer.route.start.instructions': ['Select the source. The next step will reveal only directed destinations.', '选择来源。下一步只会显示有向可达的目标。'], + 'viewer.route.copy.success': ['Traced route link copied', '已复制路径链接'], + 'viewer.route.copy.failed': ['Could not copy traced route link', '无法复制路径链接'], + 'viewer.route.position': ['Route position {index} of {total}: {label}', '路径位置 {index}/{total}:{label}'], + 'viewer.route.step': ['Step {index} of {total} · {phase} · {label}', '第 {index}/{total} 步 · {phase} · {label}'], + 'viewer.route.motionRequired': ['Automatic journey requires Live motion', '自动旅程需要动态模式'], + 'viewer.route.trigger.clear': ['Clear traced route', '清除已追踪路径'], + 'viewer.route.overview.status': ['{nodes} · {hops} · shortest authored route', '{nodes} · {hops} · 最短编写路径'], + 'viewer.route.overview.node.one': ['{count} node', '{count} 个节点'], + 'viewer.route.overview.node.other': ['{count} nodes', '{count} 个节点'], + 'viewer.route.overview.hop.one': ['{count} directed hop', '{count} 个有向跳转'], + 'viewer.route.overview.hop.other': ['{count} directed hops', '{count} 个有向跳转'], + 'viewer.route.phase.playing': ['Playing', '播放中'], + 'viewer.route.phase.complete': ['Complete', '已完成'], + 'viewer.route.phase.inspecting': ['Inspecting', '检查中'], + 'viewer.route.destination.count.one': ['{count} directed destination available. Pick a highlighted node.', '有 {count} 个有向目标可用。请选择高亮节点。'], + 'viewer.route.destination.count.other': ['{count} directed destinations available. Pick a highlighted node.', '有 {count} 个有向目标可用。请选择高亮节点。'], + 'viewer.route.noOutgoing': ['No outgoing route starts here. Clear and choose another source.', '此处没有可用的出向路径。请清除后选择其他来源。'], + 'viewer.route.result.title': ['{source} to {target}', '{source} 到 {target}'], + 'viewer.route.finder.source.title': ['Choose route start', '选择路径起点'], + 'viewer.route.finder.source.placeholder': ['Search route sources', '搜索路径来源'], + 'viewer.route.finder.source.empty': ['No matching route sources', '没有匹配的路径来源'], + 'viewer.route.finder.source.results': ['Nodes that can start a route', '可作为路径起点的节点'], + 'viewer.route.finder.source.noun': ['route sources', '个路径来源'], + 'viewer.route.finder.source.badge': ['start', '起点'], + 'viewer.route.finder.target.title': ['Destination from {label}', '从{label}出发的目标'], + 'viewer.route.finder.target.placeholder': ['Search reachable destinations', '搜索可达目标'], + 'viewer.route.finder.target.empty': ['No matching reachable destinations', '没有匹配的可达目标'], + 'viewer.route.finder.target.results': ['Reachable route destinations', '可达路径目标'], + 'viewer.route.finder.target.noun': ['reachable destinations', '个可达目标'], + 'viewer.route.hop.one': ['{count} hop', '{count} 跳'], + 'viewer.route.hop.other': ['{count} hops', '{count} 跳'], + + + 'viewer.radar.title': ['Semantic radar', '语义雷达'], + 'viewer.radar.building': ['Building overview', '正在构建总览'], + 'viewer.radar.openFull': ['Open full semantic radar', '打开完整语义雷达'], + 'viewer.radar.open': ['Open radar', '打开雷达'], + 'viewer.radar.close': ['Close semantic radar', '关闭语义雷达'], + 'viewer.radar.surface': ['Diagram overview. Click a node to focus it, or use arrow keys to pan.', '图表总览。点击节点进行聚焦,或使用方向键平移。'], + 'viewer.radar.click': ['Click node', '点击节点'], + 'viewer.radar.drag': ['Drag to pan', '拖动平移'], + 'viewer.radar.space': ['Semantic radar needs more MAP space.', '语义雷达需要更多地图可见空间。'], + 'viewer.radar.nodes': ['Semantic diagram radar nodes', '语义图表雷达节点'], + 'viewer.radar.focus': ['Focus {label} from Semantic Radar', '从语义雷达聚焦{label}'], + 'viewer.radar.status': ['{count} nodes · {viewport}', '{count} 个节点 · {viewport}'], + 'viewer.radar.fullMap': ['{count} nodes · full map', '{count} 个节点 · 完整地图'], + 'viewer.radar.compacted': ['Radar compacted to avoid covering the Semantic Passport or MAP controls.', '已收紧雷达,避免遮挡语义护照或地图控件。'], + 'viewer.radar.cancelWaiting': ['Cancel semantic radar waiting for more MAP space', '取消等待更多地图空间的语义雷达'], + 'viewer.radar.needsSpace': ['Semantic radar needs more visible MAP space', '语义雷达需要更多可见地图空间'], + 'viewer.radar.viewport.full': ['full map', '完整地图'], + 'viewer.radar.viewport.width': ['{percent}% width', '宽度 {percent}%'], + 'viewer.radar.viewport.scale': ['{percent}% viewport', '视口 {percent}%'], + + 'viewer.nav.controls': ['Diagram view controls', '图表视图控制'], + 'viewer.nav.route': ['Trace a directed route', '追踪有向路径'], + 'viewer.nav.route.title': ['Trace route (R)', '追踪路径(R)'], + 'viewer.nav.route.short': ['PATH', '路径'], + 'viewer.nav.radar': ['Open semantic radar', '打开语义雷达'], + 'viewer.nav.radar.title': ['Semantic radar (M)', '语义雷达(M)'], + 'viewer.nav.radar.short': ['MAP', '地图'], + 'viewer.nav.find': ['Find a node', '查找节点'], + 'viewer.nav.find.title': ['Find a node (/)', '查找节点(/)'], + 'viewer.nav.zoomOut': ['Zoom out', '缩小'], + 'viewer.nav.zoomOut.title': ['Zoom out (-)', '缩小(-)'], + 'viewer.nav.reset': ['Reset diagram view', '重置图表视图'], + 'viewer.nav.reset.title': ['Reset view (0)', '重置视图(0)'], + 'viewer.nav.read': ['READ', '阅读'], + 'viewer.nav.zoomIn': ['Zoom in', '放大'], + 'viewer.nav.zoomIn.title': ['Zoom in (+)', '放大(+)'], + 'viewer.nav.camera': ['{hint}. Reset diagram view', '{hint}。重置图表视图'], + 'viewer.nav.camera.title': ['{semantic}{hint} · reset view (0)', '{semantic}{hint} · 重置视图(0)'], + 'viewer.nav.camera.semantic': ['Semantic camera active · ', '语义相机已启用 · '], + 'viewer.nav.level.map': ['MAP', '概览'], + 'viewer.nav.level.read': ['READ', '阅读'], + 'viewer.nav.level.full': ['FULL', '完整'], + 'viewer.nav.level.auto': ['AUTO', '自动'], + 'viewer.nav.detail.map': ['Zoom in to reveal relationship labels and node context', '放大以显示关系标签和节点上下文'], + 'viewer.nav.detail.read': ['Zoom in again to reveal tags and annotations', '再次放大以显示标签和注释'], + 'viewer.nav.detail.full': ['Full diagram detail', '完整图表详情'], + + 'viewer.intent.summary': ['{label}. {out} outgoing, {in} incoming{loops}. {total} connections. Press Enter for details.', '{label}。{out} 条出向,{in} 条入向{loops}。共 {total} 条连接。按 Enter 查看详情。'], + 'viewer.intent.loops': [', {count} self loop', ',{count} 条自环'], + + 'viewer.common.copied': ['Copied', '已复制'], + 'viewer.common.copyFailed': ['Copy failed', '复制失败'], + 'viewer.common.copyLink': ['Copy link', '复制链接'], + 'viewer.common.clear': ['Clear', '清除'], + 'viewer.common.close': ['Close', '关闭'], +}; + +for (const [key, messages] of Object.entries(MESSAGE_PAIRS)) { + if (messages.length !== SUPPORTED_LOCALES.length || messages.some((message) => typeof message !== 'string')) { + throw new Error(`Incomplete Archify i18n tuple ${JSON.stringify(key)}`); + } +} + +const CATALOGS = Object.fromEntries(SUPPORTED_LOCALES.map((locale, index) => [ + locale, + Object.fromEntries(Object.entries(MESSAGE_PAIRS).map(([key, pair]) => [key, pair[index]])), +])); + +export function resolveLocale(locale) { + return SUPPORTED_LOCALES.includes(locale) ? locale : DEFAULT_LOCALE; +} + +export function formatMessage(template, values = {}) { + return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => ( + Object.hasOwn(values, key) ? String(values[key]) : match + )); +} + +export function translateMessage(locale, key, values = {}) { + const resolved = resolveLocale(locale); + if (!Object.hasOwn(CATALOGS[resolved], key)) { + throw new Error(`Missing Archify i18n message ${JSON.stringify(key)} for ${resolved}`); + } + return formatMessage(CATALOGS[resolved][key], values); +} + +export function translateCount(locale, key, count, values = {}) { + const suffix = count === 1 ? 'one' : 'other'; + return translateMessage(locale, `${key}.${suffix}`, { ...values, count }); +} + +export function viewerCatalog(locale) { + const resolved = resolveLocale(locale); + return Object.fromEntries(Object.entries(CATALOGS[resolved]).filter(([key]) => key.startsWith('viewer.'))); +} + +export function localizeTemplate(template, locale) { + return template.replace(/\{\{i18n:([a-zA-Z0-9_.-]+)\}\}/g, (_match, key) => escapeHtml(translateMessage(locale, key))); +} + +export function catalogKeys() { + return Object.keys(MESSAGE_PAIRS); +} diff --git a/visualization/archify-core/renderers/shared/layout-report.mjs b/visualization/archify-core/renderers/shared/layout-report.mjs new file mode 100644 index 0000000..3128a65 --- /dev/null +++ b/visualization/archify-core/renderers/shared/layout-report.mjs @@ -0,0 +1,40 @@ +/** Serialize computed layout for dry-run / inspect (#9). */ + +export function componentBox(c) { + return { + id: c.id, + type: c.type, + label: c.label, + x: Math.round(c.x), + y: Math.round(c.y), + width: c.width, + height: c.height, + ...(Number.isInteger(c.row) ? { row: c.row } : {}), + ...(Number.isInteger(c.col) ? { col: c.col } : {}), + ...(Array.isArray(c.pos) ? { pos: c.pos.map(Math.round) } : {}), + }; +} + +export function boundaryBox(b) { + return { + kind: b.kind, + label: b.label, + x: Math.round(b.x), + y: Math.round(b.y), + width: Math.round(b.width), + height: Math.round(b.height), + wraps: b.wraps, + }; +} + +export function connectionPath(conn, routed, labelAt) { + return { + from: conn.from, + to: conn.to, + label: conn.label ?? null, + variant: conn.variant ?? 'default', + route: conn.route ?? 'auto', + points: routed.points.map(([x, y]) => [Math.round(x), Math.round(y)]), + ...(labelAt ? { labelAt: labelAt.map(Math.round) } : {}), + }; +} diff --git a/visualization/archify-core/renderers/shared/text-fit.mjs b/visualization/archify-core/renderers/shared/text-fit.mjs new file mode 100644 index 0000000..31c0b17 --- /dev/null +++ b/visualization/archify-core/renderers/shared/text-fit.mjs @@ -0,0 +1,49 @@ +// Single-line node text fitting, shared by every renderer. +// +// Node text (`label`, `sublabel`, `tag`) renders as one element with +// text-anchor="middle" and is never wrapped. Left unmeasured, an over-long +// value silently spills across its neighbours while validation still reports +// a clean receipt — the failure mode this module exists to close. +// +// Two halves, always used together: +// - fittedNodeFontSize shrinks the text toward a legible minimum at render +// time, so ordinary overruns simply get smaller instead of overlapping. +// - minimumNodeTextWidth reports the width the text still needs once it has +// shrunk as far as it may, so validation can reject what shrinking cannot +// save. +// +// The geometry constants below are shared; the per-field `preferred` and +// `minimum` font sizes are not, because renderers set node text at different +// sizes (architecture sublabels are 9px, the rest are 7px). + +import { textUnits } from './utils.mjs'; + +// widthFactor: px of advance width per text unit, per px of font size. +// horizontalPadding: total px reserved inside the box so text never touches +// the border. +export const nodeTextFit = { + widthFactor: 0.6, + horizontalPadding: 8, +}; + +// Largest font size at or below `preferred` that fits `text` inside `width`, +// floored at `minimum` — below that the text is no longer legible and the +// caller should be reporting a problem instead. +export function fittedNodeFontSize(text, width, preferred, minimum) { + const units = Math.max(1, textUnits(text)); + const available = Math.max(1, width - nodeTextFit.horizontalPadding); + const fitted = Math.min(preferred, available / (units * nodeTextFit.widthFactor)); + return Math.max(minimum, Math.floor(fitted * 10) / 10); +} + +// Width `text` occupies at its legible minimum. Compare against +// `width - nodeTextFit.horizontalPadding` to decide whether shrink-to-fit can +// rescue it. +export function minimumNodeTextWidth(text, minimum) { + return textUnits(text) * minimum * nodeTextFit.widthFactor; +} + +// Available text width inside a box of `width`. +export function availableNodeTextWidth(width) { + return width - nodeTextFit.horizontalPadding; +} diff --git a/visualization/archify-core/renderers/shared/utils.mjs b/visualization/archify-core/renderers/shared/utils.mjs new file mode 100644 index 0000000..0620ac0 --- /dev/null +++ b/visualization/archify-core/renderers/shared/utils.mjs @@ -0,0 +1,193 @@ +import { + escapeHtml as esc, + localizeTemplate, + resolveLocale, + translateMessage, + viewerCatalog, +} from './i18n.mjs'; + +export { esc }; + +export function renderDefinitions() { + return ` + + + + + + + + + + + + + + + + + `; +} + +const SIGIL_TONE = { + frontend: 'frontend', + start: 'frontend', + backend: 'backend', + active: 'backend', + database: 'database', + success: 'database', + cloud: 'cloud', + waiting: 'cloud', + security: 'security', + failure: 'security', + messagebus: 'messagebus', + external: 'external', + neutral: 'external', +}; + +const SIGIL_SHAPE = { + frontend: ` + + + `, + backend: ``, + database: ` + `, + cloud: ``, + security: ` + `, + messagebus: ` + + + `, + external: ` + `, + start: ` + `, + active: ``, + waiting: ``, + success: ` + `, + failure: ` + `, + neutral: ` + `, +}; + +// A quiet, renderer-owned role stamp. It is authored SVG content rather than a +// viewer overlay, so it survives canonical export while adding no focus target, +// accessible name, layout box, or interaction state of its own. +export function renderSemanticSigil(kind, { x, y, size = 11 } = {}) { + const normalized = Object.hasOwn(SIGIL_SHAPE, kind) ? kind : 'neutral'; + const tone = SIGIL_TONE[normalized] || 'external'; + const scale = size / 16; + return ``; +} + +const SVG_SLOT_RE = / [\s\S]*? /; +const SUBTITLE_SLOT_RE = /^([ \t]*)

\[Subtitle description\]<\/p>[ \t]*(\r?\n)?/m; +const I18N_PLACEHOLDER = ' '; + +function serializeScriptJson(value) { + return JSON.stringify(value) + .replaceAll('<', '\\u003c') + .replaceAll('>', '\\u003e') + .replaceAll('&', '\\u0026'); +} + +const TEMPLATE_PLACEHOLDERS = [ + '', + '[PROJECT NAME] Architecture Diagram', + '

[PROJECT NAME] Architecture

', +]; + +export function applyTemplate(template, { + title, + subtitle, + svg, + locale, + visualPreset = 'signal-flow', +}) { + if (!SVG_SLOT_RE.test(template)) { + throw new Error('applyTemplate: template missing ARCHIFY:SVG_SLOT sentinel'); + } + if (!SUBTITLE_SLOT_RE.test(template)) { + throw new Error('applyTemplate: template missing subtitle placeholder'); + } + for (const ph of TEMPLATE_PLACEHOLDERS) { + if (!template.includes(ph)) { + throw new Error(`applyTemplate: template missing placeholder ${JSON.stringify(ph)}`); + } + } + // Function replacers: a literal `$&`, `$'`, `$\`` or `$$` in titles, labels, + // or rendered SVG must not be interpreted as a replacement pattern. + const resolvedLocale = resolveLocale(locale); + const i18nJson = serializeScriptJson({ locale: resolvedLocale, messages: viewerCatalog(resolvedLocale) }); + const renderedSubtitle = typeof subtitle === 'string' && subtitle.trim() + ? `

${esc(subtitle)}

` + : ''; + const i18nData = ` `; + const localizedTemplate = localizeTemplate(template, resolvedLocale); + const templateWithI18n = localizedTemplate.includes(I18N_PLACEHOLDER) + ? localizedTemplate.replace(I18N_PLACEHOLDER, () => i18nData) + : localizedTemplate; + return templateWithI18n + .replace(TEMPLATE_PLACEHOLDERS[0], () => ``) + .replace(TEMPLATE_PLACEHOLDERS[1], () => `${esc(translateMessage(resolvedLocale, 'page.title', { title }))}`) + .replace(TEMPLATE_PLACEHOLDERS[2], () => `

${esc(title)}

`) + .replace(SUBTITLE_SLOT_RE, (_match, indent, newline = '') => renderedSubtitle + ? `${indent}${renderedSubtitle}${newline}` + : '') + .replace(SVG_SLOT_RE, () => svg); +} + +// CJK and other wide/fullwidth glyphs render at roughly twice the advance +// width of ASCII in the monospace stacks the template uses. Keep halfwidth +// forms (notably U+FF61–U+FF9F Katakana) out of this set. The explicit ranges +// also cover vertical punctuation and supplementary East Asian scripts that +// literal glyph ranges made difficult to audit. +// Code points that take two columns of advance width: East Asian Wide and +// Fullwidth per UAX #11, tracking Unicode 17.0. That takes in the BMP symbols +// carrying emoji presentation (U+2705, U+2B50, U+26A1, U+231B, ...), which +// render at the same square advance as the supplementary-plane emoji already +// listed here, and Hangul Jamo Extended-A. Two boundary calls worth naming: +// Unicode 16.0 reclassified the trigrams (U+2630-U+2637) and the monogram / +// digram symbols (U+268A-U+268F) from Neutral to Wide, so both are in; and +// Hangul Jamo Extended-A stops at U+A97C, its last assigned jamo, because +// U+A97D-U+A97F are unassigned, and unassigned code points outside the CJK +// ranges UAX #11 names default to Neutral rather than Wide. Spelled out as +// ranges because V8 has no \p{East_Asian_Width=W} property escape. +const FULLWIDTH_RE = /[\u1100-\u115F\u231A-\u231B\u2329-\u232A\u23E9-\u23EC\u23F0\u23F3\u25FD-\u25FE\u2614-\u2615\u2630-\u2637\u2648-\u2653\u267F\u268A-\u268F\u2693\u26A1\u26AA-\u26AB\u26BD-\u26BE\u26C4-\u26C5\u26CE\u26D4\u26EA\u26F2-\u26F3\u26F5\u26FA\u26FD\u2705\u270A-\u270B\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B-\u2B1C\u2B50\u2B55\u2E80-\uA4CF\uA960-\uA97C\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF01-\uFF60\uFFE0-\uFFE6\u{16FE0}-\u{18DFF}\u{1AFF0}-\u{1AFFF}\u{1B000}-\u{1B2FF}\u{1F000}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u; + +// A variation selector (U+FE00-U+FE0F) carries no advance of its own: it +// re-presents the character before it. VS15 (U+FE0E) asks for text +// presentation, which renders narrow; VS16 (U+FE0F) asks for emoji +// presentation, which renders at the square emoji advance. So a base plus a +// selector is measured from the selector, not from the base -- otherwise +// widening the emoji-presentation bases above turns U+2B50 U+FE0F from two +// units into three while the glyph on screen stays one square, and leaves +// U+2708 U+FE0F at two only because its base happens to be narrow. +// +// A selector following a base that cannot take emoji presentation is +// malformed input; measuring it wide is the safe direction here, since +// over-measuring pads a box while under-measuring spills the label out of it. +const VARIATION_SELECTOR_FIRST = 0xfe00; +const VARIATION_SELECTOR_LAST = 0xfe0f; +const VARIATION_SELECTOR_TEXT = 0xfe0e; +const VARIATION_SELECTOR_EMOJI = 0xfe0f; + +export function textUnits(text) { + const chars = Array.from(String(text ?? '')); + let units = 0; + for (let i = 0; i < chars.length; i += 1) { + const codePoint = chars[i].codePointAt(0); + if (codePoint >= VARIATION_SELECTOR_FIRST && codePoint <= VARIATION_SELECTOR_LAST) continue; + const next = i + 1 < chars.length ? chars[i + 1].codePointAt(0) : -1; + if (next === VARIATION_SELECTOR_EMOJI) units += 2; + else if (next === VARIATION_SELECTOR_TEXT) units += 1; + else units += FULLWIDTH_RE.test(chars[i]) ? 2 : 1; + } + return units; +} diff --git a/visualization/archify-core/renderers/shared/validator.mjs b/visualization/archify-core/renderers/shared/validator.mjs new file mode 100644 index 0000000..2677938 --- /dev/null +++ b/visualization/archify-core/renderers/shared/validator.mjs @@ -0,0 +1,84 @@ +// simplify-codebase adaptation: the upstream generated validator includes all +// five Archify diagram types. This vendored core renders Architecture only, so +// keep the validation boundary small and dependency-free. +import { throwDiagnosticError } from './diagnostics.mjs'; + +const ID = /^[a-zA-Z][a-zA-Z0-9_-]*$/; + +function problem(path, message) { + return { + code: 'schema/cleanup-architecture', + severity: 'error', + message: `${path} ${message}`, + subject: { diagramType: 'architecture', path }, + evidence: {}, + supportedFixes: [], + }; +} + +export function validateSchema(diagramType, data) { + if (diagramType !== 'architecture') { + throw new Error(`validateSchema: vendored cleanup core supports architecture only, received "${diagramType}"`); + } + const diagnostics = []; + if (!data || typeof data !== 'object' || Array.isArray(data)) { + diagnostics.push(problem('/', 'must be an object')); + } else { + if (data.schema_version !== 1) diagnostics.push(problem('/schema_version', 'must equal 1')); + if (data.diagram_type !== 'architecture') diagnostics.push(problem('/diagram_type', 'must equal "architecture"')); + if (!data.meta || typeof data.meta.title !== 'string' || !data.meta.title.trim()) { + diagnostics.push(problem('/meta/title', 'must be a non-empty string')); + } + if (!Array.isArray(data.components) || data.components.length < 1) { + diagnostics.push(problem('/components', 'must contain at least one component')); + } + if (!Array.isArray(data.connections)) diagnostics.push(problem('/connections', 'must be an array')); + + const componentIds = new Set(); + for (const [index, component] of (data.components || []).entries()) { + const path = `/components/${index}`; + if (!component || typeof component !== 'object') { + diagnostics.push(problem(path, 'must be an object')); + continue; + } + if (!ID.test(component.id || '')) diagnostics.push(problem(`${path}/id`, 'must be a stable semantic ID')); + else if (componentIds.has(component.id)) diagnostics.push(problem(`${path}/id`, `duplicates "${component.id}"`)); + else componentIds.add(component.id); + if (typeof component.label !== 'string' || !component.label.trim()) diagnostics.push(problem(`${path}/label`, 'must be non-empty')); + if (!Array.isArray(component.pos) || component.pos.length !== 2 || component.pos.some((value) => !Number.isFinite(value))) { + diagnostics.push(problem(`${path}/pos`, 'must contain two finite coordinates')); + } + } + + const connectionIds = new Set(); + for (const [index, connection] of (data.connections || []).entries()) { + const path = `/connections/${index}`; + if (!connection || typeof connection !== 'object') { + diagnostics.push(problem(path, 'must be an object')); + continue; + } + if (connection.id !== undefined) { + if (!ID.test(connection.id)) diagnostics.push(problem(`${path}/id`, 'must be a stable semantic ID')); + else if (connectionIds.has(connection.id)) diagnostics.push(problem(`${path}/id`, `duplicates "${connection.id}"`)); + else connectionIds.add(connection.id); + } + if (!componentIds.has(connection.from)) diagnostics.push(problem(`${path}/from`, `references unknown component "${connection.from}"`)); + if (!componentIds.has(connection.to)) diagnostics.push(problem(`${path}/to`, `references unknown component "${connection.to}"`)); + } + + for (const [index, view] of (data.meta?.views || []).entries()) { + if (!ID.test(view.id || '')) diagnostics.push(problem(`/meta/views/${index}/id`, 'must be a stable semantic ID')); + if (!Array.isArray(view.focus) || !view.focus.length) diagnostics.push(problem(`/meta/views/${index}/focus`, 'must contain semantic IDs')); + for (const id of view.focus || []) { + if (!componentIds.has(id) && !connectionIds.has(id)) diagnostics.push(problem(`/meta/views/${index}/focus`, `references unknown semantic ID "${id}"`)); + } + } + } + + if (diagnostics.length) { + throwDiagnosticError( + `architecture validation failed:\n${diagnostics.map((entry) => ` ${entry.message}`).join('\n')}`, + diagnostics, + ); + } +} diff --git a/visualization/cleanup-extension.css b/visualization/cleanup-extension.css new file mode 100644 index 0000000..863f93d --- /dev/null +++ b/visualization/cleanup-extension.css @@ -0,0 +1,394 @@ +/* Simplify Codebase controls layered onto the vendored Archify viewer. + The graph carries topology and state. This layer exposes only the cleanup + workflow and decision evidence that the graph cannot express by itself. */ +html[data-cleanup-artifact="true"] .header { + margin: 0.15rem 0 0.78rem; + padding: 0 12.5rem 0.8rem 0; + border-bottom: 1px solid color-mix(in srgb, var(--panel-border) 72%, transparent); +} + +html[data-cleanup-artifact="true"] .header-row { + gap: 0; + margin-bottom: 0; +} + +html[data-cleanup-artifact="true"] .header-row::after, +html[data-cleanup-artifact="true"] .pulse-dot { + display: none; + content: none; +} + +html[data-cleanup-artifact="true"] .header h1 { + color: var(--text); + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 1.08rem; + font-weight: 720; + line-height: 1.35; + letter-spacing: -0.018em; + text-wrap: balance; +} + +html[data-cleanup-artifact="true"] .header .subtitle { + max-width: 72ch; + margin: 0.24rem 0 0; + color: color-mix(in srgb, var(--text) 64%, var(--bg)); + font-size: 0.72rem; + line-height: 1.48; + text-wrap: pretty; +} + +.cleanup-stage-panel { + position: relative; + width: 100%; + margin: 0 0 0.45rem; + padding: 0; + overflow: hidden; + border: 1px solid var(--panel-border); + border-radius: 0.68rem; + background: color-mix(in srgb, var(--toolbar-bg) 82%, var(--bg)); +} + +.cleanup-analysis-header { + display: grid; + grid-template-columns: minmax(10.5rem, auto) minmax(0, 1fr); + align-items: stretch; + gap: 0; +} + +.cleanup-finding-strip { + display: flex; + flex: 0 0 auto; + align-items: center; + align-content: center; + flex-wrap: wrap; + gap: 0.36rem; + padding: 0.72rem 0.8rem; + border-right: 1px solid var(--panel-border); + background: color-mix(in srgb, var(--toolbar-bg) 56%, transparent); +} + +.cleanup-finding-button { + position: relative; + width: 2.5rem; + height: 2.5rem; + border: 1px solid var(--toolbar-border); + border-radius: 0.34rem; + background: transparent; + color: var(--text-muted); + font: 700 0.7rem/1 'JetBrains Mono', ui-monospace, monospace; + transition: border-color 160ms ease, background 160ms ease, color 160ms ease; +} + +.cleanup-finding-button::after { + content: ""; + position: absolute; + right: 0.3rem; + bottom: 0.3rem; + width: 0.28rem; + height: 0.28rem; + border-radius: 50%; + background: var(--text-muted); +} + +.cleanup-finding-button[data-disposition="ranked"]::after { background: var(--frontend-stroke); } +.cleanup-finding-button[data-disposition="changed"]::after { background: var(--database-stroke); } +.cleanup-finding-button[data-disposition="unresolved"]::after { background: var(--security-stroke); } +.cleanup-finding-button[data-disposition="rejected"]::after { background: var(--security-stroke); } +.cleanup-finding-button:hover { border-color: var(--frontend-stroke); color: var(--text); } +.cleanup-finding-button:focus-visible, +.cleanup-stage-button:focus-visible, +.cleanup-evidence-toggle:focus-visible { + outline: 2px solid var(--frontend-stroke); + outline-offset: 2px; +} +.cleanup-finding-button[aria-pressed="true"] { + border-color: var(--frontend-stroke); + background: var(--frontend-fill); + color: var(--text); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--frontend-stroke) 28%, transparent); +} + +.cleanup-finding-identity { + display: flex; + width: min(100%, 74ch); + min-width: 0; + flex-direction: column; + justify-content: center; + padding: 0.72rem 1rem 0.76rem; +} + +.cleanup-finding-title { + color: var(--text); + font-size: 1rem; + font-weight: 720; + line-height: 1.38; + text-wrap: balance; +} + +.cleanup-finding-summary { + max-width: 78ch; + margin-top: 0.3rem; + color: color-mix(in srgb, var(--text) 68%, var(--bg)); + font-size: 0.77rem; + line-height: 1.52; + text-wrap: pretty; +} + +.cleanup-finding-meta { + display: flex; + min-width: 0; + align-items: center; + flex-wrap: wrap; + gap: 0.46rem; + margin-top: 0.4rem; + color: var(--text-muted); + font: 600 0.7rem/1.3 'JetBrains Mono', ui-monospace, monospace; + letter-spacing: 0; +} + +.cleanup-finding-meta span + span::before { + content: "·"; + margin-right: 0.48rem; + color: var(--toolbar-border); +} + +.cleanup-stage-rail { + display: grid; + grid-template-columns: auto minmax(14rem, 1fr) auto; + align-items: center; + gap: 0.8rem; + padding: 0.56rem 0.8rem; + border-top: 1px solid var(--panel-border); + background: color-mix(in srgb, var(--bg) 34%, transparent); +} + +.cleanup-stage-controls { + display: grid; + grid-template-columns: repeat(4, minmax(4.35rem, 1fr)); + overflow: hidden; + border: 1px solid var(--toolbar-border); + border-radius: 0.38rem; +} + +.cleanup-stage-caption { + max-width: 76ch; + color: var(--text-muted); + font-size: 0.7rem; + line-height: 1.48; + text-wrap: pretty; +} + +.cleanup-stage-button { + min-height: 2.5rem; + border: 0; + border-right: 1px solid var(--toolbar-border); + background: transparent; + color: var(--toolbar-text); + padding: 0 0.58rem; + font: 700 0.7rem/1 'JetBrains Mono', ui-monospace, monospace; + transition: background 160ms ease, color 160ms ease; +} + +.cleanup-stage-button:last-child { border-right: 0; } +.cleanup-stage-button:hover { background: var(--toolbar-hover); color: var(--text); } +.cleanup-stage-button[aria-pressed="true"] { + background: var(--frontend-stroke); + color: var(--bg); +} + +.cleanup-stage-key { + margin-right: 0.32rem; + opacity: 0.62; + font-size: 0.7rem; +} + +.cleanup-evidence-toggle { + min-height: 2.5rem; + border: 1px solid var(--toolbar-border); + border-radius: 0.38rem; + background: transparent; + color: var(--toolbar-text); + padding: 0 0.72rem; + font: 700 0.7rem/1 'JetBrains Mono', ui-monospace, monospace; + transition: border-color 160ms ease, background 160ms ease, color 160ms ease; +} + +.cleanup-evidence-toggle:hover { border-color: var(--frontend-stroke); color: var(--text); } +.cleanup-evidence-toggle[aria-expanded="true"] { + border-color: color-mix(in srgb, var(--frontend-stroke) 64%, var(--toolbar-border)); + background: var(--frontend-fill); + color: var(--text); +} + +.cleanup-truth-boundary { + margin: 0; + padding: 0.42rem 0.8rem 0.5rem; + border-top: 1px solid color-mix(in srgb, var(--panel-border) 64%, transparent); + color: var(--text-muted); + font-size: 0.7rem; + line-height: 1.4; +} + +.cleanup-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-items: stretch; + gap: 0.75rem; +} + +.cleanup-workspace[data-evidence-open="true"] { + grid-template-columns: minmax(0, 1fr) minmax(18rem, 21rem); +} + +.cleanup-evidence-drawer { + display: flex; + min-width: 0; + max-height: clamp(30rem, 61vh, 42rem); + flex-direction: column; + overflow: auto; + border: 1px solid var(--panel-border); + border-radius: 1rem; + background: color-mix(in srgb, var(--toolbar-bg) 92%, var(--bg)); +} + +.cleanup-evidence-drawer[hidden] { display: none; } + +.cleanup-evidence-item { + min-width: 0; + padding: 0.9rem 1rem; + border-bottom: 1px solid var(--panel-border); +} + +.cleanup-evidence-item:last-child { border-bottom: 0; } +.cleanup-evidence-item[hidden] { display: none; } + +.cleanup-evidence-label { + display: block; + margin-bottom: 0.3rem; + color: var(--frontend-stroke); + font: 700 0.7rem/1 'JetBrains Mono', ui-monospace, monospace; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.cleanup-evidence-value { + max-width: 72ch; + color: var(--text); + font-size: 0.72rem; + line-height: 1.58; +} + +.cleanup-evidence-unknown .cleanup-evidence-label { color: var(--security-stroke); } +.cleanup-report-link { + display: inline-block; + margin-top: 0.42rem; + color: var(--frontend-stroke); + font: 700 0.7rem/1 'JetBrains Mono', ui-monospace, monospace; + text-decoration: none; +} +.cleanup-report-link:hover { text-decoration: underline; } + +.cleanup-passport-locus { + max-width: 100%; + overflow: hidden; + color: var(--frontend-stroke); + font: 600 0.7rem/1.4 'JetBrains Mono', ui-monospace, monospace; + text-overflow: ellipsis; + text-decoration: none; + white-space: nowrap; +} +.cleanup-passport-locus[href]:hover { text-decoration: underline; } + +html[data-cleanup-artifact="true"][data-theme="light"] .cleanup-evidence-item:not(.cleanup-evidence-unknown) .cleanup-evidence-label, +html[data-cleanup-artifact="true"][data-theme="light"] .cleanup-report-link, +html[data-cleanup-artifact="true"][data-theme="light"] .cleanup-passport-locus { + color: var(--text-muted); +} + +html[data-cleanup-artifact="true"] .diagram-nav button { + min-width: 2.4rem; + height: 2.4rem; +} + +html[data-cleanup-artifact="true"] .diagram-nav [data-view="reset"], +html[data-cleanup-artifact="true"] .diagram-nav [data-view="reset"][data-detail-visible] { + min-width: 4.5rem; + padding-inline: 0.46rem; +} + +html[data-cleanup-artifact="true"] .diagram-container[data-camera-mode="semantic"] .diagram-nav [data-view="reset"] { + background: color-mix(in srgb, var(--frontend-fill) 72%, transparent); + color: var(--text); +} + +/* This is a desktop investigation surface: give the visual analysis enough + vertical room to keep node labels and relationship geometry legible. */ +html:not([data-embed="true"]) .diagram-container { + min-width: 0; + min-height: clamp(30rem, 61vh, 42rem); +} + +html:not([data-embed="true"]) .diagram-container > svg { + display: block; + width: 100%; + min-height: 27rem; +} + +/* Archify may add data-nav-stage-rail after its first layout probe. Keep the + Cleanup reader's vertical geometry identical before and after that probe so + ordinary laptop viewports do not jump upward after first paint. */ +@media (min-width: 768px) and (max-height: 920px) { + html[data-cleanup-artifact="true"] body { padding-block: 0.375rem; } +} + +html[data-cleanup-ready="true"] svg [data-node-id], +html[data-cleanup-ready="true"] svg [data-edge-id] { + transition: opacity 180ms cubic-bezier(0.22, 1, 0.36, 1); +} +svg [data-node-id][data-cleanup-muted="true"] { + opacity: 0.36 !important; + filter: saturate(0.38); +} +svg [data-edge-id][data-cleanup-muted="true"] { + opacity: 0.3 !important; + filter: saturate(0.3); +} +svg [data-node-id][data-focus-selected] { + opacity: 1 !important; + filter: drop-shadow(0 0 11px color-mix(in srgb, var(--frontend-stroke) 78%, transparent)) !important; +} +svg [data-node-id][data-focus-selected] rect:not(.c-mask) { + stroke: var(--frontend-stroke) !important; + stroke-width: 3.25px !important; +} +svg [data-cleanup-absent="true"] { display: none !important; } +svg [data-cleanup-primary="true"] rect:not(.c-mask) { + stroke: var(--frontend-stroke) !important; + stroke-width: 3px !important; + filter: drop-shadow(0 0 8px color-mix(in srgb, var(--frontend-stroke) 58%, transparent)); +} +svg [data-cleanup-cut="true"] rect:not(.c-mask) { + stroke: var(--security-stroke) !important; + stroke-width: 3px !important; + filter: drop-shadow(0 0 8px color-mix(in srgb, var(--security-stroke) 48%, transparent)); +} +svg path[data-cleanup-cut="true"] { + stroke: var(--security-stroke) !important; + stroke-width: 3px !important; + stroke-dasharray: 8 5; +} +svg g[data-edge-id][data-cleanup-cut="true"] text { fill: var(--security-stroke) !important; } + +@media (prefers-reduced-motion: reduce) { + .cleanup-finding-button, + .cleanup-stage-button, + .cleanup-evidence-toggle, + html[data-cleanup-ready="true"] svg [data-node-id], + html[data-cleanup-ready="true"] svg [data-edge-id] { transition: none; } +} + +@media print { + .cleanup-stage-panel, + .cleanup-evidence-drawer { display: none !important; } + .cleanup-workspace { display: block; } +} diff --git a/visualization/cleanup-extension.js b/visualization/cleanup-extension.js new file mode 100644 index 0000000..76b9e6c --- /dev/null +++ b/visualization/cleanup-extension.js @@ -0,0 +1,447 @@ +(function () { + 'use strict'; + var source = document.getElementById('cleanup-map-data'); + var diagram = document.querySelector('.diagram-container'); + if (!source || !diagram) return; + + var data = JSON.parse(source.textContent); + var isChinese = data.meta.locale === 'zh-CN'; + var findingById = new Map(data.findings.map(function (finding) { return [finding.id, finding]; })); + var nodeById = new Map(data.nodes.map(function (node) { return [node.id, node]; })); + var relationshipById = new Map(data.relationships.map(function (relationship) { return [relationship.id, relationship]; })); + var currentFinding = data.findings[0]; + var currentStage = data.meta.mode === 'change' ? 'before' : 'locate'; + var cleanupInitialized = false; + var cameraFrame = 0; + + var copy = isChinese ? { + findings: '问题列表', + stages: '分析步骤', + showEvidence: '查看证据', + hideEvidence: '收起证据', + proof: '关键证据', + verification: '验证结果', + consequence: '删除影响', + unknown: '待确认', + report: '查看完整报告 ↗', + confidence: '可信度', + risk: '风险', + truth: '图里只画已经确认的关系。能连通,不代表运行时一定受影响,也不代表可以安全删除。', + } : { + findings: 'Cleanup findings', + stages: 'Cleanup stages', + showEvidence: 'Show evidence', + hideEvidence: 'Hide evidence', + proof: 'Decisive proof', + verification: 'Verification receipt', + consequence: 'Consequence', + unknown: 'Unresolved', + report: 'Proof record ↗', + confidence: 'confidence', + risk: 'risk', + truth: 'Only confirmed relationships are shown. Authored reachability is not runtime impact or deletion safety.', + }; + + var stageEntries = data.meta.mode === 'change' + ? [['before', isChinese ? '改前' : 'BEFORE'], ['cut', isChinese ? '删除内容' : 'CUT'], ['after', isChinese ? '改后' : 'AFTER'], ['verify', isChinese ? '验证' : 'VERIFY']] + : [['locate', isChinese ? '定位' : 'LOCATE'], ['trace', isChinese ? '追踪' : 'TRACE'], ['cut', isChinese ? '删除范围' : 'CUT'], ['decide', isChinese ? '判断' : 'DECIDE']]; + + var stageCaptions = data.meta.mode === 'change' + ? (isChinese ? { + before: '这里是修改前已经确认的关系。', + cut: '红色部分是这次实际删除的节点和关系。', + after: '这里是修改后保留下来的所有者和消费者路径。', + verify: '结合右侧验证结果,确认这次删除有没有破坏原有行为。', + } : { + before: 'Read the confirmed relationships before the change.', + cut: 'See the nodes and relationships actually removed by this change.', + after: 'Read the surviving owner-to-consumer path after the change.', + verify: 'Judge the cut against an independent verification receipt.', + }) + : (isChinese ? { + locate: '先看高亮节点。点开节点可以查看对应的源码位置。', + trace: '沿着高亮关系,看它从哪里接收调用,又影响了哪些消费者。', + cut: '红色部分是建议删除的节点和关系,灰色部分是保留的上下文。', + decide: '结合右侧的证据、删除影响和待确认项,决定是否进入修改。', + } : { + locate: 'Start with the highlighted candidate; click it to open its source passport.', + trace: 'Follow confirmed relationships from the entrypoint through the candidate to its consumers.', + cut: 'Highlighted semantics are proposed for removal; gray context remains visible.', + decide: 'Return to full context and judge the candidate using proof, consequence, and unresolved facts.', + }); + + var dispositionLabels = isChinese ? { + ranked: '建议清理', retained: '保留', rejected: '不处理', unresolved: '待确认', changed: '已完成', + } : {}; + var levelLabels = isChinese ? { high: '高', medium: '中', low: '低' } : {}; + var roleLabels = isChinese ? { + entrypoint: '入口', owner: '所有者', candidate: '候选', consumer: '消费者', + state: '状态', store: '存储', boundary: '边界', external: '外部', + } : {}; + + function element(tag, className, text) { + var node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } + + function translatedDisposition(value) { return dispositionLabels[value] || value; } + function translatedLevel(value) { return levelLabels[value] || value; } + function isDecisionStage(stage) { return stage === 'decide' || stage === 'verify'; } + + var panel = element('section', 'cleanup-stage-panel no-print'); + panel.id = 'cleanup-stage-panel'; + panel.setAttribute('aria-label', copy.stages); + panel.title = copy.truth; + + var analysisHeader = element('div', 'cleanup-analysis-header'); + var findingStrip = element('nav', 'cleanup-finding-strip'); + findingStrip.id = 'cleanup-finding-strip'; + findingStrip.setAttribute('aria-label', copy.findings); + analysisHeader.appendChild(findingStrip); + + var identity = element('div', 'cleanup-finding-identity'); + var title = element('strong', 'cleanup-finding-title'); + title.id = 'cleanup-finding-title'; + var findingSummary = element('p', 'cleanup-finding-summary'); + findingSummary.id = 'cleanup-finding-summary'; + var findingMeta = element('div', 'cleanup-finding-meta'); + findingMeta.id = 'cleanup-finding-meta'; + identity.appendChild(title); + identity.appendChild(findingSummary); + identity.appendChild(findingMeta); + analysisHeader.appendChild(identity); + panel.appendChild(analysisHeader); + + var stageRail = element('div', 'cleanup-stage-rail'); + var stageControls = element('nav', 'cleanup-stage-controls'); + stageControls.id = 'cleanup-stage-controls'; + stageControls.setAttribute('aria-label', copy.stages); + stageRail.appendChild(stageControls); + + var stageCaption = element('p', 'cleanup-stage-caption'); + stageCaption.id = 'cleanup-stage-caption'; + stageCaption.setAttribute('aria-live', 'polite'); + stageRail.appendChild(stageCaption); + + var evidenceToggle = element('button', 'cleanup-evidence-toggle', copy.showEvidence); + evidenceToggle.id = 'cleanup-evidence-toggle'; + evidenceToggle.type = 'button'; + evidenceToggle.setAttribute('aria-controls', 'cleanup-evidence-drawer'); + evidenceToggle.setAttribute('aria-expanded', 'false'); + stageRail.appendChild(evidenceToggle); + panel.appendChild(stageRail); + panel.appendChild(element('p', 'cleanup-truth-boundary', copy.truth)); + + var evidenceDrawer = element('aside', 'cleanup-evidence-drawer'); + evidenceDrawer.id = 'cleanup-evidence-drawer'; + evidenceDrawer.hidden = true; + + function evidenceItem(className, label) { + var item = element('section', 'cleanup-evidence-item ' + className); + item.appendChild(element('span', 'cleanup-evidence-label', label)); + var value = element('p', 'cleanup-evidence-value'); + item.appendChild(value); + evidenceDrawer.appendChild(item); + return { item: item, value: value }; + } + + var proofItem = evidenceItem('cleanup-evidence-proof', copy.proof); + var consequenceItem = evidenceItem('cleanup-evidence-consequence', copy.consequence); + var unknownItem = evidenceItem('cleanup-evidence-unknown', copy.unknown); + + var workspace = element('div', 'cleanup-workspace'); + workspace.id = 'cleanup-workspace'; + workspace.dataset.evidenceOpen = 'false'; + var reportHost = diagram.parentNode; + reportHost.insertBefore(panel, diagram); + reportHost.insertBefore(workspace, diagram); + workspace.appendChild(diagram); + workspace.appendChild(evidenceDrawer); + + var passportMeta = document.getElementById('focus-passport-meta'); + var passportId = document.getElementById('focus-id'); + var passportKind = document.getElementById('focus-kind'); + var passportLocus = element('a', 'cleanup-passport-locus'); + passportLocus.id = 'cleanup-passport-locus'; + passportLocus.hidden = true; + if (passportMeta) passportMeta.appendChild(passportLocus); + + function updatePassportLocus() { + if (!passportId || !passportLocus) return; + var record = nodeById.get(passportId.textContent.trim()); + var locus = record && record.locus; + if (record && passportKind) passportKind.textContent = roleLabels[record.role] || record.role; + if (!locus) { + passportLocus.hidden = true; + passportLocus.removeAttribute('href'); + passportLocus.textContent = ''; + return; + } + passportLocus.textContent = locus.path + (locus.line ? ':' + locus.line : '') + (locus.symbol ? ' · ' + locus.symbol : ''); + if (locus.href) { + passportLocus.href = locus.href; + passportLocus.target = '_blank'; + passportLocus.rel = 'noopener noreferrer'; + } else passportLocus.removeAttribute('href'); + passportLocus.hidden = false; + } + if (passportId) new MutationObserver(updatePassportLocus).observe(passportId, { childList: true, characterData: true, subtree: true }); + + function semanticNodes() { return Array.prototype.slice.call(document.querySelectorAll('svg [data-node-id]')); } + function semanticEdges() { return Array.prototype.slice.call(document.querySelectorAll('svg [data-edge-id]')); } + + function clearMarks() { + semanticNodes().concat(semanticEdges()).forEach(function (node) { + ['data-cleanup-muted', 'data-cleanup-absent', 'data-cleanup-primary', 'data-cleanup-cut'].forEach(function (name) { node.removeAttribute(name); }); + }); + } + + function resetNative() { + try { if (window.Archify?.routeProbe) window.Archify.routeProbe.clear({ updateUrl: false, restoreFocus: false }); } catch (_) {} + try { if (window.Archify?.focus) window.Archify.focus.clear({ updateUrl: false, preserveView: true }); } catch (_) {} + } + + function markFocused(nodeIds, edgeIds) { + var nodes = new Set(nodeIds || []); + var edges = new Set(edgeIds || []); + semanticNodes().forEach(function (node) { if (!nodes.has(node.getAttribute('data-node-id'))) node.setAttribute('data-cleanup-muted', 'true'); }); + semanticEdges().forEach(function (edge) { if (!edges.has(edge.getAttribute('data-edge-id'))) edge.setAttribute('data-cleanup-muted', 'true'); }); + } + + function markCut(finding) { + var cutNodes = new Set(finding.cut?.nodes || []); + var cutEdges = new Set(finding.cut?.relationships || []); + markFocused(Array.from(cutNodes), Array.from(cutEdges)); + semanticNodes().forEach(function (node) { if (cutNodes.has(node.getAttribute('data-node-id'))) node.setAttribute('data-cleanup-cut', 'true'); }); + semanticEdges().forEach(function (edge) { if (cutEdges.has(edge.getAttribute('data-edge-id'))) edge.setAttribute('data-cleanup-cut', 'true'); }); + } + + function applySnapshot(snapshot) { + var nodes = new Set(snapshot.nodes); + var edges = new Set(snapshot.relationships); + semanticNodes().forEach(function (node) { if (!nodes.has(node.getAttribute('data-node-id'))) node.setAttribute('data-cleanup-absent', 'true'); }); + semanticEdges().forEach(function (edge) { if (!edges.has(edge.getAttribute('data-edge-id'))) edge.setAttribute('data-cleanup-absent', 'true'); }); + } + + function routeFor(finding) { + if (!finding.route || !window.Archify?.routeProbe) return false; + try { + window.Archify.routeProbe.begin({ source: finding.route.from, focusNode: false }); + return window.Archify.routeProbe.choose(finding.route.to, { updateUrl: false }); + } catch (_) { return false; } + } + + function uniqueIds(ids) { + return Array.from(new Set((ids || []).filter(function (id) { return nodeById.has(id); }))); + } + + function cutNodeIds(finding) { + var ids = (finding.cut?.nodes || []).slice(); + (finding.cut?.relationships || []).forEach(function (relationshipId) { + var relationship = relationshipById.get(relationshipId); + if (relationship) ids.push(relationship.from, relationship.to); + }); + return uniqueIds(ids); + } + + function stageNodeIds(finding, stage) { + if (data.meta.mode === 'change') { + if (stage === 'before') return uniqueIds(data.change.before.nodes); + if (stage === 'cut') return cutNodeIds(finding); + return uniqueIds(data.change.after.nodes); + } + if (stage === 'locate') return [finding.primary]; + if (stage === 'cut') return cutNodeIds(finding); + return uniqueIds(finding.related); + } + + function frameStage(finding, stage) { + var ids = stageNodeIds(finding, stage); + if (!ids.length || !window.Archify?.view?.reveal) return false; + if (cameraFrame) window.cancelAnimationFrame(cameraFrame); + cameraFrame = window.requestAnimationFrame(function () { + cameraFrame = window.requestAnimationFrame(function () { + cameraFrame = 0; + window.Archify.view.reveal(ids, { + includeNeighbors: stage === 'locate', + maxScale: stage === 'locate' ? 1.4 : 1.35, + duration: 260, + reason: 'cleanup-' + stage, + }); + }); + }); + return true; + } + + function scheduleStageCamera(finding, stage) { + if (stage === 'trace' && finding.route) { + if (cameraFrame) window.cancelAnimationFrame(cameraFrame); + cameraFrame = window.requestAnimationFrame(function () { + cameraFrame = window.requestAnimationFrame(function () { + cameraFrame = 0; + if (!routeFor(finding)) { + markFocused(finding.related, []); + frameStage(finding, stage); + } + }); + }); + return; + } + frameStage(finding, stage); + } + + function setEvidenceOpen(open) { + evidenceDrawer.hidden = !open; + workspace.dataset.evidenceOpen = String(open); + evidenceToggle.textContent = open ? copy.hideEvidence : copy.showEvidence; + evidenceToggle.setAttribute('aria-expanded', String(open)); + } + + function toggleEvidence() { + setEvidenceOpen(evidenceDrawer.hidden); + frameStage(currentFinding, currentStage); + } + + function updateIdentity(finding) { + title.textContent = finding.title; + title.title = finding.title; + findingSummary.textContent = finding.summary; + findingMeta.replaceChildren(); + findingMeta.appendChild(element('span', '', translatedDisposition(finding.disposition))); + findingMeta.appendChild(element('span', '', isChinese + ? copy.confidence + ':' + translatedLevel(finding.confidence) + : translatedLevel(finding.confidence) + ' ' + copy.confidence)); + if (finding.risk) findingMeta.appendChild(element('span', '', isChinese + ? copy.risk + ':' + translatedLevel(finding.risk) + : translatedLevel(finding.risk) + ' ' + copy.risk)); + } + + function updateEvidence(finding, stage) { + var proofLabel = proofItem.item.querySelector('.cleanup-evidence-label'); + proofLabel.textContent = stage === 'verify' && data.change ? copy.verification : copy.proof; + proofItem.value.textContent = stage === 'verify' && data.change ? data.change.verification : finding.proof; + + consequenceItem.item.hidden = !finding.consequence; + consequenceItem.value.textContent = finding.consequence || ''; + var unknowns = finding.unknowns || []; + unknownItem.item.hidden = unknowns.length === 0; + unknownItem.value.textContent = unknowns.join(' · '); + + var previousLink = proofItem.item.querySelector('.cleanup-report-link'); + if (previousLink) previousLink.remove(); + if (finding.report_url) { + var link = element('a', 'cleanup-report-link', copy.report); + link.href = finding.report_url; + proofItem.item.appendChild(link); + } + } + + function updateButtons() { + findingStrip.replaceChildren(); + data.findings.forEach(function (finding) { + var button = element('button', 'cleanup-finding-button', finding.id); + button.type = 'button'; + button.dataset.disposition = finding.disposition; + button.title = finding.title; + button.setAttribute('aria-label', finding.id + ' · ' + finding.title); + button.setAttribute('aria-pressed', String(finding.id === currentFinding.id)); + button.addEventListener('click', function () { activate(finding.id, stageEntries[0][0], true); }); + findingStrip.appendChild(button); + }); + + stageControls.replaceChildren(); + stageEntries.forEach(function (entry, index) { + var button = element('button', 'cleanup-stage-button'); + button.type = 'button'; + button.appendChild(element('span', 'cleanup-stage-key', String(index + 1))); + button.appendChild(document.createTextNode(entry[1])); + button.setAttribute('aria-pressed', String(entry[0] === currentStage)); + button.addEventListener('click', function () { activate(currentFinding.id, entry[0], true); }); + stageControls.appendChild(button); + }); + stageCaption.textContent = stageCaptions[currentStage] || ''; + } + + function updateHash() { + var params = new URLSearchParams(); + params.set('finding', currentFinding.id); + params.set('stage', currentStage); + history.replaceState(null, '', '#' + params.toString()); + } + + function activate(findingId, stage, writeHash, preserveNativeFocus) { + var finding = findingById.get(findingId) || data.findings[0]; + if (!stageEntries.some(function (entry) { return entry[0] === stage; })) stage = stageEntries[0][0]; + currentFinding = finding; + currentStage = stage; + panel.dataset.stage = stage; + clearMarks(); + if (!preserveNativeFocus) resetNative(); + var svg = document.querySelector('svg'); + if (svg) svg.setAttribute('data-cleanup-stage', stage); + + if (data.meta.mode === 'change') { + if (stage === 'before') applySnapshot(data.change.before); + else if (stage === 'cut') { + applySnapshot(data.change.before); + markCut(finding); + } + else applySnapshot(data.change.after); + } else if (stage === 'locate') { + markFocused([finding.primary], []); + var primary = document.querySelector('svg [data-node-id="' + CSS.escape(finding.primary) + '"]'); + if (primary) primary.setAttribute('data-cleanup-primary', 'true'); + } else if (stage === 'trace') { + if (!finding.route) markFocused(finding.related, []); + } else if (stage === 'cut') markCut(finding); + else markFocused(finding.related, []); + + updateIdentity(finding); + updateEvidence(finding, stage); + updateButtons(); + setEvidenceOpen(isDecisionStage(stage)); + if (!preserveNativeFocus) scheduleStageCamera(finding, stage); + if (writeHash) updateHash(); + cleanupInitialized = true; + window.setTimeout(updatePassportLocus, 0); + } + + function restoreFromHash() { + var params = new URLSearchParams(location.hash.replace(/^#/, '')); + var finding = params.get('finding'); + var stage = params.get('stage'); + var view = params.get('view'); + var findingView = view?.indexOf('finding-') === 0; + if (!finding && findingView) finding = view.slice('finding-'.length).toUpperCase(); + var ownsHash = Boolean(finding || stage || findingView); + if (!ownsHash) { + if (!cleanupInitialized) activate(currentFinding.id, currentStage, false, Boolean(location.hash.replace(/^#/, '').trim())); + return; + } + if (findingById.has(finding)) activate(finding, stage || (findingView ? (data.meta.mode === 'change' ? 'verify' : 'decide') : stageEntries[0][0]), false); + else activate(currentFinding.id, stage || currentStage, false); + } + + evidenceToggle.addEventListener('click', toggleEvidence); + window.addEventListener('keydown', function (event) { + if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || /INPUT|TEXTAREA|SELECT/.test(event.target?.tagName || '')) return; + var index = Number(event.key) - 1; + if (index >= 0 && index < stageEntries.length) activate(currentFinding.id, stageEntries[index][0], true); + else if (event.key.toLowerCase() === 'e') { + event.preventDefault(); + toggleEvidence(); + } + }); + window.addEventListener('hashchange', function () { window.setTimeout(restoreFromHash, 0); }); + window.CleanupMap = { + activate: activate, + toggleEvidence: toggleEvidence, + current: function () { return { finding: currentFinding.id, stage: currentStage, evidence: !evidenceDrawer.hidden }; }, + }; + restoreFromHash(); + window.requestAnimationFrame(function () { + document.documentElement.setAttribute('data-cleanup-ready', 'true'); + }); +}()); diff --git a/visualization/cleanup-map.schema.json b/visualization/cleanup-map.schema.json new file mode 100644 index 0000000..225e18e --- /dev/null +++ b/visualization/cleanup-map.schema.json @@ -0,0 +1,181 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/tt-a1i/simplify-codebase/visualization/cleanup-map.schema.json", + "title": "Simplify Codebase cleanup map", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "map_type", "meta", "nodes", "relationships", "findings"], + "allOf": [ + { + "if": { + "properties": { "meta": { "type": "object", "properties": { "mode": { "const": "change" } }, "required": ["mode"] } } + }, + "then": { + "required": ["change"], + "properties": { + "findings": { + "type": "array", + "maxItems": 1, + "items": { "type": "object", "properties": { "disposition": { "const": "changed" } } } + } + } + }, + "else": { + "properties": { + "findings": { + "type": "array", + "items": { "type": "object", "properties": { "disposition": { "const": "ranked" } } } + } + } + } + } + ], + "properties": { + "schema_version": { "const": 1 }, + "map_type": { "const": "cleanup" }, + "meta": { + "type": "object", + "additionalProperties": false, + "required": ["title", "mode", "scope"], + "properties": { + "title": { "type": "string", "minLength": 1, "maxLength": 72 }, + "mode": { "enum": ["survey", "change"] }, + "scope": { "enum": ["focused", "broad"] }, + "repository": { "type": "string" }, + "revision": { "type": "string" }, + "locale": { "enum": ["en", "zh-CN"] } + } + }, + "nodes": { + "type": "array", + "minItems": 1, + "maxItems": 18, + "items": { "$ref": "#/$defs/node" } + }, + "relationships": { + "type": "array", + "maxItems": 32, + "items": { "$ref": "#/$defs/relationship" } + }, + "findings": { + "type": "array", + "minItems": 1, + "maxItems": 5, + "items": { "$ref": "#/$defs/finding" } + }, + "change": { "$ref": "#/$defs/change" } + }, + "$defs": { + "id": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9_-]*$" + }, + "idList": { + "type": "array", + "uniqueItems": true, + "items": { "$ref": "#/$defs/id" } + }, + "locus": { + "type": "object", + "additionalProperties": false, + "required": ["path"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "line": { "type": "integer", "minimum": 1 }, + "symbol": { "type": "string" }, + "href": { "type": "string" } + } + }, + "node": { + "type": "object", + "additionalProperties": false, + "required": ["id", "label", "role"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "label": { "type": "string", "minLength": 1, "maxLength": 30 }, + "role": { + "enum": ["entrypoint", "owner", "candidate", "consumer", "state", "store", "boundary", "external"] + }, + "locus": { "$ref": "#/$defs/locus" }, + "column": { "type": "integer", "minimum": 0, "maximum": 5 } + } + }, + "relationship": { + "type": "object", + "additionalProperties": false, + "required": ["id", "from", "to", "label", "kind", "evidence"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "from": { "$ref": "#/$defs/id" }, + "to": { "$ref": "#/$defs/id" }, + "label": { "type": "string", "minLength": 1, "maxLength": 28 }, + "kind": { "enum": ["call", "data", "registration", "publication", "lifecycle", "dependency"] }, + "evidence": { "const": "confirmed" } + } + }, + "route": { + "type": "object", + "additionalProperties": false, + "required": ["from", "to"], + "properties": { + "from": { "$ref": "#/$defs/id" }, + "to": { "$ref": "#/$defs/id" } + } + }, + "cut": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "nodes": { "$ref": "#/$defs/idList" }, + "relationships": { "$ref": "#/$defs/idList" } + } + }, + "finding": { + "type": "object", + "additionalProperties": false, + "required": ["id", "title", "disposition", "confidence", "summary", "primary", "related", "cut", "proof"], + "properties": { + "id": { "type": "string", "pattern": "^S[1-9][0-9]*$" }, + "title": { "type": "string", "minLength": 1, "maxLength": 56 }, + "disposition": { "enum": ["ranked", "retained", "rejected", "unresolved", "changed"] }, + "confidence": { "enum": ["high", "medium", "low"] }, + "risk": { "enum": ["high", "medium", "low"] }, + "summary": { "type": "string", "minLength": 1, "maxLength": 180 }, + "primary": { "$ref": "#/$defs/id" }, + "related": { "$ref": "#/$defs/idList" }, + "route": { "$ref": "#/$defs/route" }, + "cut": { "$ref": "#/$defs/cut" }, + "proof": { "type": "string", "minLength": 1, "maxLength": 240 }, + "consequence": { "type": "string", "maxLength": 160 }, + "unknowns": { + "type": "array", + "maxItems": 3, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1, "maxLength": 140 } + }, + "report_url": { "type": "string" } + } + }, + "snapshot": { + "type": "object", + "additionalProperties": false, + "required": ["nodes", "relationships"], + "properties": { + "nodes": { "$ref": "#/$defs/idList" }, + "relationships": { "$ref": "#/$defs/idList" } + } + }, + "change": { + "type": "object", + "additionalProperties": false, + "required": ["finding_id", "before", "after", "verification"], + "properties": { + "finding_id": { "type": "string", "pattern": "^S[1-9][0-9]*$" }, + "before": { "$ref": "#/$defs/snapshot" }, + "after": { "$ref": "#/$defs/snapshot" }, + "verification": { "type": "string", "minLength": 1, "maxLength": 240 } + } + } + } +} diff --git a/visualization/examples/change.cleanup-map.json b/visualization/examples/change.cleanup-map.json new file mode 100644 index 0000000..4b3b1e9 --- /dev/null +++ b/visualization/examples/change.cleanup-map.json @@ -0,0 +1,55 @@ +{ + "schema_version": 1, + "map_type": "cleanup", + "meta": { + "title": "运行时分发路径修改结果", + "mode": "change", + "scope": "focused", + "repository": "example/service", + "revision": "working-tree-example", + "locale": "zh-CN" + }, + "nodes": [ + { "id": "entrypoint", "label": "HTTP 入口", "role": "entrypoint", "locus": { "path": "src/http/route.ts", "line": 18 }, "column": 0 }, + { "id": "coordinator", "label": "分发协调器", "role": "owner", "locus": { "path": "src/runtime/coordinator.ts", "line": 42 }, "column": 1 }, + { "id": "legacyRouter", "label": "旧路由适配器", "role": "candidate", "locus": { "path": "src/runtime/legacy-router.ts", "line": 11 }, "column": 2 }, + { "id": "handler", "label": "主处理器", "role": "owner", "locus": { "path": "src/runtime/handler.ts", "line": 27 }, "column": 3 }, + { "id": "publisher", "label": "结果发布器", "role": "consumer", "locus": { "path": "src/runtime/publisher.ts", "line": 33 }, "column": 4 } + ], + "relationships": [ + { "id": "requestDispatch", "from": "entrypoint", "to": "coordinator", "label": "发起分发", "kind": "call", "evidence": "confirmed" }, + { "id": "legacyDelegate", "from": "coordinator", "to": "legacyRouter", "label": "重新编码操作键", "kind": "call", "evidence": "confirmed" }, + { "id": "canonicalDelegate", "from": "legacyRouter", "to": "handler", "label": "转交处理", "kind": "call", "evidence": "confirmed" }, + { "id": "directDelegate", "from": "coordinator", "to": "handler", "label": "直接转交", "kind": "call", "evidence": "confirmed" }, + { "id": "resultPublish", "from": "handler", "to": "publisher", "label": "发布结果", "kind": "publication", "evidence": "confirmed" } + ], + "findings": [ + { + "id": "S1", + "title": "重复的操作路由层", + "disposition": "changed", + "confidence": "high", + "risk": "medium", + "summary": "旧路由适配器和两条转交关系已经删除,coordinator 现在直接调用 handler。", + "primary": "legacyRouter", + "related": ["entrypoint", "coordinator", "legacyRouter", "handler", "publisher"], + "route": { "from": "coordinator", "to": "legacyRouter" }, + "cut": { "nodes": ["legacyRouter"], "relationships": ["legacyDelegate", "canonicalDelegate"] }, + "proof": "改为直接调用后,分发集成测试和结果发布边界对比均通过。", + "consequence": "旧的操作键表示和适配器导入路径不再受支持。", + "unknowns": [] + } + ], + "change": { + "finding_id": "S1", + "before": { + "nodes": ["entrypoint", "coordinator", "legacyRouter", "handler", "publisher"], + "relationships": ["requestDispatch", "legacyDelegate", "canonicalDelegate", "resultPublish"] + }, + "after": { + "nodes": ["entrypoint", "coordinator", "handler", "publisher"], + "relationships": ["requestDispatch", "directDelegate", "resultPublish"] + }, + "verification": "残留搜索没有发现旧引用;分发集成测试、包测试和构建均通过;差异检查完成。本内容仅为示例。" + } +} diff --git a/visualization/examples/survey.cleanup-map.json b/visualization/examples/survey.cleanup-map.json new file mode 100644 index 0000000..36e80f2 --- /dev/null +++ b/visualization/examples/survey.cleanup-map.json @@ -0,0 +1,75 @@ +{ + "schema_version": 1, + "map_type": "cleanup", + "meta": { + "title": "运行时分发路径清理分析", + "mode": "survey", + "scope": "focused", + "repository": "example/service", + "revision": "example-only", + "locale": "zh-CN" + }, + "nodes": [ + { + "id": "entrypoint", + "label": "HTTP 入口", + "role": "entrypoint", + "locus": { "path": "src/http/route.ts", "line": 18, "symbol": "handleRequest" }, + "column": 0 + }, + { + "id": "coordinator", + "label": "分发协调器", + "role": "owner", + "locus": { "path": "src/runtime/coordinator.ts", "line": 42, "symbol": "dispatch" }, + "column": 1 + }, + { + "id": "legacyRouter", + "label": "旧路由适配器", + "role": "candidate", + "locus": { "path": "src/runtime/legacy-router.ts", "line": 11, "symbol": "routeLegacy" }, + "column": 2 + }, + { + "id": "handler", + "label": "主处理器", + "role": "owner", + "locus": { "path": "src/runtime/handler.ts", "line": 27, "symbol": "run" }, + "column": 3 + }, + { + "id": "publisher", + "label": "结果发布器", + "role": "consumer", + "locus": { "path": "src/runtime/publisher.ts", "line": 33, "symbol": "publish" }, + "column": 4 + } + ], + "relationships": [ + { "id": "requestDispatch", "from": "entrypoint", "to": "coordinator", "label": "发起分发", "kind": "call", "evidence": "confirmed" }, + { "id": "legacyDelegate", "from": "coordinator", "to": "legacyRouter", "label": "重新编码操作键", "kind": "call", "evidence": "confirmed" }, + { "id": "canonicalDelegate", "from": "legacyRouter", "to": "handler", "label": "转交处理", "kind": "call", "evidence": "confirmed" }, + { "id": "resultPublish", "from": "handler", "to": "publisher", "label": "发布结果", "kind": "publication", "evidence": "confirmed" } + ], + "findings": [ + { + "id": "S1", + "title": "重复的操作路由层", + "disposition": "ranked", + "confidence": "high", + "risk": "medium", + "summary": "legacyRouter 只是重新编码操作键,再交给已经能直接处理它的 handler;它没有独立行为。", + "primary": "legacyRouter", + "related": ["entrypoint", "coordinator", "legacyRouter", "handler", "publisher"], + "route": { "from": "entrypoint", "to": "publisher" }, + "cut": { + "nodes": ["legacyRouter"], + "relationships": ["legacyDelegate", "canonicalDelegate"] + }, + "proof": "让 coordinator 直接调用 handler 后,分发集成测试仍应得到相同的发布结果。", + "consequence": "删除 legacyRouter 后,旧的操作键表示和适配器导入路径将不再保留。", + "unknowns": ["尚未确认仓库外部是否仍有包依赖旧的导入路径。"] + } + ] +} diff --git a/visualization/render-cleanup-map.mjs b/visualization/render-cleanup-map.mjs new file mode 100644 index 0000000..10cfded --- /dev/null +++ b/visualization/render-cleanup-map.mjs @@ -0,0 +1,575 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const archifyRendererPath = path.join(moduleDir, 'archify-core/renderers/architecture/render-architecture.mjs'); +const cleanupExtensionCssPath = path.join(moduleDir, 'cleanup-extension.css'); +const cleanupExtensionJsPath = path.join(moduleDir, 'cleanup-extension.js'); +const COLUMN_STEP = 258; +const ROW_STEP = 150; +const ID = /^[A-Za-z][A-Za-z0-9_-]*$/; +const FINDING_ID = /^S[1-9][0-9]*$/; +const ROLES = new Set(['entrypoint', 'owner', 'candidate', 'consumer', 'state', 'store', 'boundary', 'external']); +const KINDS = new Set(['call', 'data', 'registration', 'publication', 'lifecycle', 'dependency']); +const DISPOSITIONS = new Set(['ranked', 'retained', 'rejected', 'unresolved', 'changed']); +const LEVELS = new Set(['high', 'medium', 'low']); +const TEXT_LIMITS = Object.freeze({ + metaTitle: 72, + nodeLabel: 30, + relationshipLabel: 28, + findingTitle: 56, + findingSummary: 180, + proof: 240, + consequence: 160, + unknown: 140, + verification: 240, +}); + +function usage() { + return `Usage: + node visualization/render-cleanup-map.mjs validate + node visualization/render-cleanup-map.mjs render [output.html] + node visualization/render-cleanup-map.mjs deliver [output.html] + node visualization/render-cleanup-map.mjs check `; +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function duplicateValues(values) { + const seen = new Set(); + const duplicates = new Set(); + for (const value of values) { + if (seen.has(value)) duplicates.add(value); + seen.add(value); + } + return [...duplicates]; +} + +function rejectUnknownKeys(value, allowed, pointer, problems) { + if (!isObject(value)) return; + for (const key of Object.keys(value)) { + if (!allowed.has(key)) problems.push(`${pointer}/${key} is not part of the cleanup-map contract.`); + } +} + +function rejectLongText(value, limit, pointer, problems) { + if (typeof value === 'string' && value.trim().length > limit) { + problems.push(`${pointer} must contain at most ${limit} characters; move detail to the canonical text report.`); + } +} + +function validateIdList(value, pointer, known, problems) { + if (!Array.isArray(value)) { + problems.push(`${pointer} must be an array.`); + return; + } + for (const [index, id] of value.entries()) { + if (typeof id !== 'string' || !ID.test(id)) problems.push(`${pointer}/${index} must be a stable semantic ID.`); + else if (!known.has(id)) problems.push(`${pointer}/${index} references unknown ID ${JSON.stringify(id)}.`); + } + for (const duplicate of duplicateValues(value)) problems.push(`${pointer} duplicates ${JSON.stringify(duplicate)}.`); +} + +function validatePortableLink(value, pointer, problems) { + if (typeof value !== 'string' || !value.trim()) { + problems.push(`${pointer} must be a non-empty portable link.`); + return; + } + const link = value.trim(); + if (link !== value || /[\u0000-\u001F\u007F]/.test(link)) { + problems.push(`${pointer} must not contain surrounding whitespace or control characters.`); + return; + } + const hasScheme = /^[A-Za-z][A-Za-z0-9+.-]*:/.test(link); + if ((hasScheme && !link.toLowerCase().startsWith('https://')) || link.startsWith('//') || link.startsWith('\\')) { + problems.push(`${pointer} must be an HTTPS URL or a relative/hash link.`); + } +} + +function validateSnapshot(snapshot, pointer, nodeIds, relationshipIds, relationshipById, problems) { + if (!isObject(snapshot)) { + problems.push(`${pointer} must be an object.`); + return { nodes: new Set(), relationships: new Set() }; + } + rejectUnknownKeys(snapshot, new Set(['nodes', 'relationships']), pointer, problems); + validateIdList(snapshot.nodes, `${pointer}/nodes`, nodeIds, problems); + validateIdList(snapshot.relationships, `${pointer}/relationships`, relationshipIds, problems); + const nodes = new Set(Array.isArray(snapshot.nodes) ? snapshot.nodes : []); + const relationships = new Set(Array.isArray(snapshot.relationships) ? snapshot.relationships : []); + for (const relationshipId of relationships) { + const relationship = relationshipById.get(relationshipId); + if (relationship && (!nodes.has(relationship.from) || !nodes.has(relationship.to))) { + problems.push(`${pointer}/relationships includes ${JSON.stringify(relationshipId)} without both endpoint nodes.`); + } + } + return { nodes, relationships }; +} + +export function shortestDirectedPath(relationships, source, target) { + if (source === target) return { nodes: [source], relationships: [] }; + const outgoing = new Map(); + for (const relationship of relationships) { + const links = outgoing.get(relationship.from) || []; + links.push(relationship); + outgoing.set(relationship.from, links); + } + const previous = new Map([[source, null]]); + const queue = [source]; + for (let cursor = 0; cursor < queue.length && !previous.has(target); cursor += 1) { + for (const relationship of outgoing.get(queue[cursor]) || []) { + if (previous.has(relationship.to)) continue; + previous.set(relationship.to, { from: queue[cursor], relationship }); + queue.push(relationship.to); + if (relationship.to === target) break; + } + } + if (!previous.has(target)) return null; + const nodes = [target]; + const route = []; + let current = target; + while (current !== source) { + const step = previous.get(current); + if (!step) return null; + nodes.unshift(step.from); + route.unshift(step.relationship.id); + current = step.from; + } + return { nodes, relationships: route }; +} + +export function validateDocument(document) { + const problems = []; + if (!isObject(document)) return ['Document must be a JSON object.']; + rejectUnknownKeys(document, new Set(['schema_version', 'map_type', 'meta', 'nodes', 'relationships', 'findings', 'change']), '', problems); + if (document.schema_version !== 1) problems.push('/schema_version must equal 1.'); + if (document.map_type !== 'cleanup') problems.push('/map_type must equal "cleanup".'); + + const meta = document.meta; + if (!isObject(meta)) problems.push('/meta must be an object.'); + else { + rejectUnknownKeys(meta, new Set(['title', 'mode', 'scope', 'repository', 'revision', 'locale']), '/meta', problems); + if (typeof meta.title !== 'string' || !meta.title.trim()) problems.push('/meta/title must be non-empty.'); + rejectLongText(meta.title, TEXT_LIMITS.metaTitle, '/meta/title', problems); + if (!['survey', 'change'].includes(meta.mode)) problems.push('/meta/mode must be survey or change.'); + if (!['focused', 'broad'].includes(meta.scope)) problems.push('/meta/scope must be focused or broad.'); + if (meta.repository !== undefined && typeof meta.repository !== 'string') problems.push('/meta/repository must be a string.'); + if (meta.revision !== undefined && typeof meta.revision !== 'string') problems.push('/meta/revision must be a string.'); + if (meta.locale !== undefined && !['en', 'zh-CN'].includes(meta.locale)) problems.push('/meta/locale must be en or zh-CN.'); + } + + const nodes = Array.isArray(document.nodes) ? document.nodes : []; + if (!Array.isArray(document.nodes) || nodes.length < 1 || nodes.length > 18) problems.push('/nodes must contain 1 to 18 nodes.'); + const nodeIds = new Set(); + for (const [index, node] of nodes.entries()) { + const pointer = `/nodes/${index}`; + if (!isObject(node)) { + problems.push(`${pointer} must be an object.`); + continue; + } + rejectUnknownKeys(node, new Set(['id', 'label', 'role', 'locus', 'column']), pointer, problems); + if (typeof node.id !== 'string' || !ID.test(node.id)) problems.push(`${pointer}/id must be a stable semantic ID.`); + else if (nodeIds.has(node.id)) problems.push(`${pointer}/id duplicates ${JSON.stringify(node.id)}.`); + else nodeIds.add(node.id); + if (typeof node.label !== 'string' || !node.label.trim()) problems.push(`${pointer}/label must be non-empty.`); + rejectLongText(node.label, TEXT_LIMITS.nodeLabel, `${pointer}/label`, problems); + if (!ROLES.has(node.role)) problems.push(`${pointer}/role is unsupported.`); + if (node.column !== undefined && (!Number.isInteger(node.column) || node.column < 0 || node.column > 5)) { + problems.push(`${pointer}/column must be an integer from 0 to 5.`); + } + if (node.locus !== undefined) { + if (!isObject(node.locus) || typeof node.locus.path !== 'string' || !node.locus.path.trim()) { + problems.push(`${pointer}/locus must include a non-empty path.`); + } else { + rejectUnknownKeys(node.locus, new Set(['path', 'line', 'symbol', 'href']), `${pointer}/locus`, problems); + if (node.locus.line !== undefined && (!Number.isInteger(node.locus.line) || node.locus.line < 1)) problems.push(`${pointer}/locus/line must be a positive integer.`); + if (node.locus.symbol !== undefined && typeof node.locus.symbol !== 'string') problems.push(`${pointer}/locus/symbol must be a string.`); + if (node.locus.href !== undefined) validatePortableLink(node.locus.href, `${pointer}/locus/href`, problems); + } + } + } + + const relationships = Array.isArray(document.relationships) ? document.relationships : []; + if (!Array.isArray(document.relationships) || relationships.length > 32) problems.push('/relationships must contain at most 32 relationships.'); + const relationshipIds = new Set(); + const relationshipById = new Map(); + for (const [index, relationship] of relationships.entries()) { + const pointer = `/relationships/${index}`; + if (!isObject(relationship)) { + problems.push(`${pointer} must be an object.`); + continue; + } + rejectUnknownKeys(relationship, new Set(['id', 'from', 'to', 'label', 'kind', 'evidence']), pointer, problems); + if (typeof relationship.id !== 'string' || !ID.test(relationship.id)) problems.push(`${pointer}/id must be a stable semantic ID.`); + else if (relationshipIds.has(relationship.id)) problems.push(`${pointer}/id duplicates ${JSON.stringify(relationship.id)}.`); + else { + relationshipIds.add(relationship.id); + relationshipById.set(relationship.id, relationship); + } + if (!nodeIds.has(relationship.from)) problems.push(`${pointer}/from references unknown node ${JSON.stringify(relationship.from)}.`); + if (!nodeIds.has(relationship.to)) problems.push(`${pointer}/to references unknown node ${JSON.stringify(relationship.to)}.`); + if (relationship.from === relationship.to) problems.push(`${pointer} must not connect a node to itself.`); + if (typeof relationship.label !== 'string' || !relationship.label.trim()) problems.push(`${pointer}/label must be non-empty.`); + rejectLongText(relationship.label, TEXT_LIMITS.relationshipLabel, `${pointer}/label`, problems); + if (!KINDS.has(relationship.kind)) problems.push(`${pointer}/kind is unsupported.`); + if (relationship.evidence !== 'confirmed') problems.push(`${pointer}/evidence must equal "confirmed"; unresolved relationships belong in finding.unknowns.`); + } + + const findings = Array.isArray(document.findings) ? document.findings : []; + if (!Array.isArray(document.findings) || findings.length < 1 || findings.length > 5) problems.push('/findings must contain 1 to 5 visualized findings.'); + const findingIds = new Set(); + for (const [index, finding] of findings.entries()) { + const pointer = `/findings/${index}`; + if (!isObject(finding)) { + problems.push(`${pointer} must be an object.`); + continue; + } + rejectUnknownKeys(finding, new Set(['id', 'title', 'disposition', 'confidence', 'risk', 'summary', 'primary', 'related', 'route', 'cut', 'proof', 'consequence', 'unknowns', 'report_url']), pointer, problems); + if (typeof finding.id !== 'string' || !FINDING_ID.test(finding.id)) problems.push(`${pointer}/id must match S1, S2, and so on.`); + else if (findingIds.has(finding.id)) problems.push(`${pointer}/id duplicates ${JSON.stringify(finding.id)}.`); + else findingIds.add(finding.id); + if (typeof finding.title !== 'string' || !finding.title.trim()) problems.push(`${pointer}/title must be non-empty.`); + rejectLongText(finding.title, TEXT_LIMITS.findingTitle, `${pointer}/title`, problems); + if (!DISPOSITIONS.has(finding.disposition)) problems.push(`${pointer}/disposition is unsupported.`); + if (!LEVELS.has(finding.confidence)) problems.push(`${pointer}/confidence must be high, medium, or low.`); + if (finding.risk !== undefined && !LEVELS.has(finding.risk)) problems.push(`${pointer}/risk must be high, medium, or low.`); + if (typeof finding.summary !== 'string' || !finding.summary.trim()) problems.push(`${pointer}/summary must be non-empty.`); + rejectLongText(finding.summary, TEXT_LIMITS.findingSummary, `${pointer}/summary`, problems); + if (!nodeIds.has(finding.primary)) problems.push(`${pointer}/primary references unknown node ${JSON.stringify(finding.primary)}.`); + validateIdList(finding.related, `${pointer}/related`, nodeIds, problems); + const related = new Set(Array.isArray(finding.related) ? finding.related : []); + if (Array.isArray(finding.related) && !finding.related.includes(finding.primary)) problems.push(`${pointer}/related must include the primary node.`); + if (typeof finding.proof !== 'string' || !finding.proof.trim()) problems.push(`${pointer}/proof must be non-empty.`); + rejectLongText(finding.proof, TEXT_LIMITS.proof, `${pointer}/proof`, problems); + if (finding.consequence !== undefined && typeof finding.consequence !== 'string') problems.push(`${pointer}/consequence must be a string.`); + rejectLongText(finding.consequence, TEXT_LIMITS.consequence, `${pointer}/consequence`, problems); + if (finding.report_url !== undefined) validatePortableLink(finding.report_url, `${pointer}/report_url`, problems); + if (finding.route !== undefined) { + if (!isObject(finding.route) || !nodeIds.has(finding.route.from) || !nodeIds.has(finding.route.to)) { + problems.push(`${pointer}/route must reference two known nodes.`); + } else { + rejectUnknownKeys(finding.route, new Set(['from', 'to']), `${pointer}/route`, problems); + const route = shortestDirectedPath(relationships, finding.route.from, finding.route.to); + if (!route) problems.push(`${pointer}/route has no confirmed directed path.`); + else if (!route.nodes.includes(finding.primary)) problems.push(`${pointer}/route must pass through the primary node.`); + else if (route.nodes.some((nodeId) => !related.has(nodeId))) problems.push(`${pointer}/related must include every node on the route.`); + } + } + if (!isObject(finding.cut)) problems.push(`${pointer}/cut must describe at least one retired node or relationship.`); + else { + rejectUnknownKeys(finding.cut, new Set(['nodes', 'relationships']), `${pointer}/cut`, problems); + if (finding.cut.nodes !== undefined) validateIdList(finding.cut.nodes, `${pointer}/cut/nodes`, nodeIds, problems); + if (finding.cut.relationships !== undefined) validateIdList(finding.cut.relationships, `${pointer}/cut/relationships`, relationshipIds, problems); + const cutNodeCount = Array.isArray(finding.cut.nodes) ? finding.cut.nodes.length : 0; + const cutRelationshipCount = Array.isArray(finding.cut.relationships) ? finding.cut.relationships.length : 0; + if (cutNodeCount + cutRelationshipCount === 0) problems.push(`${pointer}/cut must contain at least one node or relationship.`); + const cutNodes = new Set(Array.isArray(finding.cut.nodes) ? finding.cut.nodes : []); + const cutRelationships = new Set(Array.isArray(finding.cut.relationships) ? finding.cut.relationships : []); + for (const nodeId of cutNodes) { + if (!related.has(nodeId)) problems.push(`${pointer}/related must include cut node ${JSON.stringify(nodeId)}.`); + for (const relationship of relationships) { + if ((relationship.from === nodeId || relationship.to === nodeId) && !cutRelationships.has(relationship.id)) { + problems.push(`${pointer}/cut/relationships must include ${JSON.stringify(relationship.id)} incident to cut node ${JSON.stringify(nodeId)}.`); + } + } + } + for (const relationshipId of cutRelationships) { + const relationship = relationshipById.get(relationshipId); + if (relationship && (!related.has(relationship.from) || !related.has(relationship.to))) { + problems.push(`${pointer}/related must include both endpoints of cut relationship ${JSON.stringify(relationshipId)}.`); + } + } + } + if (finding.unknowns !== undefined && (!Array.isArray(finding.unknowns) || finding.unknowns.some((item) => typeof item !== 'string' || !item.trim()))) { + problems.push(`${pointer}/unknowns must be an array of non-empty strings.`); + } + if (Array.isArray(finding.unknowns)) { + if (finding.unknowns.length > 3) problems.push(`${pointer}/unknowns must contain at most 3 decision-relevant items.`); + for (const duplicate of duplicateValues(finding.unknowns)) problems.push(`${pointer}/unknowns duplicates ${JSON.stringify(duplicate)}.`); + finding.unknowns.forEach((item, unknownIndex) => rejectLongText(item, TEXT_LIMITS.unknown, `${pointer}/unknowns/${unknownIndex}`, problems)); + } + } + + if (meta?.mode === 'survey') { + for (const [index, finding] of findings.entries()) { + if (isObject(finding) && finding.disposition !== 'ranked') problems.push(`/findings/${index}/disposition must be ranked in Survey mode.`); + } + } + + if (meta?.mode === 'change') { + const change = document.change; + if (!isObject(change)) problems.push('/change is required in Change mode.'); + else { + rejectUnknownKeys(change, new Set(['finding_id', 'before', 'after', 'verification']), '/change', problems); + if (findings.length !== 1) problems.push('/findings must contain exactly one changed Finding in Change mode.'); + const finding = findings[0]; + if (isObject(finding) && finding.disposition !== 'changed') problems.push('/findings/0/disposition must be changed in Change mode.'); + if (!findingIds.has(change.finding_id)) problems.push('/change/finding_id must reference the visualized Finding.'); + else if (isObject(finding) && change.finding_id !== finding.id) problems.push('/change/finding_id must match /findings/0/id.'); + const before = validateSnapshot(change.before, '/change/before', nodeIds, relationshipIds, relationshipById, problems); + const after = validateSnapshot(change.after, '/change/after', nodeIds, relationshipIds, relationshipById, problems); + if (isObject(finding) && isObject(finding.cut)) { + const cutNodeIds = Array.isArray(finding.cut.nodes) ? finding.cut.nodes : []; + const cutRelationshipIds = Array.isArray(finding.cut.relationships) ? finding.cut.relationships : []; + const cutNodes = new Set(cutNodeIds); + const cutRelationships = new Set(cutRelationshipIds); + for (const nodeId of cutNodeIds) { + if (!before.nodes.has(nodeId)) problems.push(`/findings/0/cut/nodes includes ${JSON.stringify(nodeId)} outside /change/before.`); + if (after.nodes.has(nodeId)) problems.push(`/findings/0/cut/nodes retains ${JSON.stringify(nodeId)} in /change/after.`); + } + for (const relationshipId of cutRelationshipIds) { + if (!before.relationships.has(relationshipId)) problems.push(`/findings/0/cut/relationships includes ${JSON.stringify(relationshipId)} outside /change/before.`); + if (after.relationships.has(relationshipId)) problems.push(`/findings/0/cut/relationships retains ${JSON.stringify(relationshipId)} in /change/after.`); + } + for (const nodeId of before.nodes) { + if (!after.nodes.has(nodeId) && !cutNodes.has(nodeId)) problems.push(`/findings/0/cut/nodes omits removed node ${JSON.stringify(nodeId)}.`); + } + for (const relationshipId of before.relationships) { + if (!after.relationships.has(relationshipId) && !cutRelationships.has(relationshipId)) problems.push(`/findings/0/cut/relationships omits removed relationship ${JSON.stringify(relationshipId)}.`); + } + } + if (typeof change.verification !== 'string' || !change.verification.trim()) problems.push('/change/verification must be non-empty.'); + rejectLongText(change.verification, TEXT_LIMITS.verification, '/change/verification', problems); + } + } else if (document.change !== undefined) { + problems.push('/change is only allowed when meta.mode is "change".'); + } + return problems; +} + +function safeScriptJson(value) { + return JSON.stringify(value).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026'); +} + +function truncate(value, length) { + const text = String(value || '').trim(); + return text.length <= length ? text : `${text.slice(0, Math.max(1, length - 1)).trimEnd()}…`; +} + +function roleVisualType(role) { + return ({ + entrypoint: 'external', + owner: 'backend', + candidate: 'security', + consumer: 'frontend', + state: 'database', + store: 'database', + boundary: 'cloud', + external: 'external', + })[role] || 'external'; +} + +function roleLabel(role, locale) { + if (locale !== 'zh-CN') return role; + return ({ + entrypoint: '入口', + owner: '所有者', + candidate: '候选', + consumer: '消费者', + state: '状态', + store: '存储', + boundary: '边界', + external: '外部', + })[role] || role; +} + +function architectureSubtitle(document) { + if (document.meta.locale !== 'zh-CN') { + return `${document.meta.mode.toUpperCase()} · ${document.meta.scope.toUpperCase()} · ${document.findings.length} ${document.findings.length === 1 ? 'FINDING' : 'FINDINGS'}`; + } + const mode = document.meta.mode === 'change' ? '修改结果' : '清理分析'; + const scope = document.meta.scope === 'broad' ? '全库范围' : '聚焦范围'; + return `${mode} · ${scope} · ${document.findings.length} 个问题`; +} + +function layoutColumns(document) { + const ranks = new Map(); + for (const node of document.nodes) if (Number.isInteger(node.column)) ranks.set(node.id, node.column); + for (const node of document.nodes) if (!ranks.has(node.id)) ranks.set(node.id, 0); + for (let pass = 0; pass < document.nodes.length; pass += 1) { + for (const relationship of document.relationships) { + const target = document.nodes.find((node) => node.id === relationship.to); + if (Number.isInteger(target?.column)) continue; + ranks.set(relationship.to, Math.min(5, Math.max(ranks.get(relationship.to), ranks.get(relationship.from) + 1))); + } + } + const columns = new Map(); + for (const node of document.nodes) { + const column = ranks.get(node.id); + const list = columns.get(column) || []; + list.push(node.id); + columns.set(column, list); + } + return { ranks, columns }; +} + +export function compileArchitecture(document) { + const problems = validateDocument(document); + if (problems.length) throw new Error(`Cleanup map validation failed:\n- ${problems.join('\n- ')}`); + + const { ranks, columns } = layoutColumns(document); + const findingIdsByNode = new Map(); + for (const finding of document.findings) { + for (const nodeId of finding.related) { + const ids = findingIdsByNode.get(nodeId) || []; + ids.push(finding.id); + findingIdsByNode.set(nodeId, ids); + } + } + const components = document.nodes.map((node) => { + const row = columns.get(ranks.get(node.id)).indexOf(node.id); + const findingIds = findingIdsByNode.get(node.id) || []; + return { + id: node.id, + type: roleVisualType(node.role), + semantic_kind: node.role, + label: truncate(node.label, 30), + sublabel: truncate(node.locus?.symbol || node.role, 34), + ...(findingIds.length ? { tag: truncate(`${findingIds.join(' · ')} · ${roleLabel(node.role, document.meta.locale)}`, 34) } : {}), + pos: [50 + ranks.get(node.id) * COLUMN_STEP, 120 + row * ROW_STEP], + size: [148, 74], + }; + }); + const componentById = new Map(components.map((component) => [component.id, component])); + const maxColumn = Math.max(...components.map((component) => Math.round((component.pos[0] - 50) / COLUMN_STEP))); + const maxRows = Math.max(...[...columns.values()].map((nodes) => nodes.length)); + + return { + schema_version: 1, + diagram_type: 'architecture', + meta: { + title: document.meta.title, + subtitle: architectureSubtitle(document), + locale: document.meta.locale || 'en', + visual_preset: 'signal-flow', + quality_profile: 'standard', + viewBox: [Math.max(620, 100 + (maxColumn + 1) * COLUMN_STEP), Math.max(380, 200 + maxRows * ROW_STEP)], + }, + components, + connections: document.relationships.map((relationship, index) => { + const from = componentById.get(relationship.from); + const to = componentById.get(relationship.to); + const skipsColumns = Math.abs(ranks.get(relationship.to) - ranks.get(relationship.from)) > 1; + const sameRow = from.pos[1] === to.pos[1]; + const needsBypass = skipsColumns && sameRow; + const corridorY = Math.max(from.pos[1] + from.size[1], to.pos[1] + to.size[1]) + 42 + (index % 2) * 22; + return { + id: relationship.id, + from: relationship.from, + to: relationship.to, + label: truncate(relationship.label, 28), + labelDy: needsBypass ? 18 : -34, + route: 'auto', + ...(needsBypass ? { + fromSide: 'bottom', + toSide: 'bottom', + via: [ + [from.pos[0] + from.size[0] / 2, corridorY], + [to.pos[0] + to.size[0] / 2, corridorY], + ], + labelSegment: 1, + } : {}), + variant: relationship.kind === 'publication' ? 'emphasis' : 'default', + }; + }), + }; +} + +function enhanceArchifyHtml(html, document) { + const css = fs.readFileSync(cleanupExtensionCssPath, 'utf8'); + const javascript = fs.readFileSync(cleanupExtensionJsPath, 'utf8'); + const semanticData = ``; + return html + .replace('\n ', ` \n`) + .replace('', ` ${semanticData}\n \n`); +} + +export function renderDocument(document) { + const architecture = compileArchitecture(document); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'cleanup-map-archify-')); + const inputPath = path.join(directory, 'architecture.json'); + const outputPath = path.join(directory, 'architecture.html'); + try { + fs.writeFileSync(inputPath, JSON.stringify(architecture, null, 2)); + const result = spawnSync(process.execPath, [archifyRendererPath, inputPath, outputPath], { + encoding: 'utf8', + env: { ...process.env, ARCHIFY_QUALITY_PROFILE: 'standard' }, + }); + if (result.status !== 0) { + throw new Error(`Vendored Archify renderer failed:\n${(result.stderr || result.stdout || '').trim()}`); + } + return enhanceArchifyHtml(fs.readFileSync(outputPath, 'utf8'), document); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +} + +export function checkArtifact(html) { + const normalizedHtml = html.toLowerCase(); + const checks = [ + ['artifact marker', html.includes('name="cleanup-map-artifact" content="2"')], + ['vendored Archify runtime', html.includes('name="generator" content="archify') && html.includes('Archify.routeProbe = (function ()')], + ['embedded semantic data', html.includes('id="cleanup-map-data"')], + ['single interactive SVG', (html.match(/ !ok)) process.exit(1); + return; + } + const inputPath = path.resolve(rawInput); + const document = readJson(inputPath); + const problems = validateDocument(document); + if (problems.length) fail(`Cleanup map validation failed:\n- ${problems.join('\n- ')}`); + console.log(`PASS cleanup-map contract validation (${document.nodes.length} nodes, ${document.relationships.length} relationships, ${document.findings.length} findings)`); + if (command === 'validate') return; + const outputPath = path.resolve(rawOutput || defaultOutput(inputPath)); + const html = renderDocument(document); + fs.writeFileSync(outputPath, html); + console.log(`WROTE ${outputPath}`); + if (command === 'deliver') { + const checks = checkArtifact(html); + for (const [name, ok] of checks) console.log(`${ok ? 'PASS' : 'FAIL'} ${name}`); + if (checks.some(([, ok]) => !ok)) process.exit(1); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + main(process.argv.slice(2)); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } +} diff --git a/visualization/test/render-cleanup-map.test.mjs b/visualization/test/render-cleanup-map.test.mjs new file mode 100644 index 0000000..ba04b11 --- /dev/null +++ b/visualization/test/render-cleanup-map.test.mjs @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + checkArtifact, + compileArchitecture, + renderDocument, + shortestDirectedPath, + validateDocument, +} from '../render-cleanup-map.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +function fixture(name) { + return JSON.parse(fs.readFileSync(path.join(root, 'examples', name), 'utf8')); +} + +test('Survey and Change fixtures satisfy the cleanup-map contract', () => { + assert.deepEqual(validateDocument(fixture('survey.cleanup-map.json')), []); + assert.deepEqual(validateDocument(fixture('change.cleanup-map.json')), []); +}); + +test('bundled JSON Schema matches the authored contract surface', () => { + const schema = JSON.parse(fs.readFileSync(path.join(root, 'cleanup-map.schema.json'), 'utf8')); + assert.equal(schema.$schema, 'https://json-schema.org/draft/2020-12/schema'); + assert.deepEqual(schema.properties.meta.required, ['title', 'mode', 'scope']); + assert.equal('summary' in schema.properties.meta.properties, false); + assert.equal('detail' in schema.$defs.node.properties, false); +}); + +test('renderer creates a checked standalone artifact for both modes', () => { + for (const name of ['survey.cleanup-map.json', 'change.cleanup-map.json']) { + const document = fixture(name); + const architecture = compileArchitecture(document); + const html = renderDocument(document); + assert.ok(checkArtifact(html).every(([, ok]) => ok), name); + assert.equal(architecture.meta.visual_preset, 'signal-flow'); + assert.equal('animation' in architecture.meta, false); + assert.equal(architecture.meta.locale, 'zh-CN'); + assert.equal(architecture.components.find((component) => component.id === 'legacyRouter').semantic_kind, 'candidate'); + assert.match(architecture.meta.subtitle, /清理分析|修改结果/); + assert.equal('legend' in architecture.meta, false); + assert.equal('views' in architecture.meta, false); + assert.equal('cards' in architecture, false); + assert.match(html, /data-preset="signal-flow"/); + assert.match(html, /data-node-id="legacyRouter"[^>]*data-node-kind="candidate"/); + assert.doesNotMatch(html, /]*data-animation="trace"/); + for (const retiredSurface of [ + /Archify\.(guidedViews|semanticLens|guide|presentation|preset)/, + /archify-guided-views-data|class="cards"/, + /id="(?:guided-views|semantic-lens|diagram-guide|btn-present|btn-preset)"/, + /viewer\.(?:guided|lens|guide|present|preset)\./, + /data-format="webm"|MediaRecorder|data-legend/, + ]) { + assert.doesNotMatch(html, retiredSurface); + } + assert.match(html, /setEvidenceOpen\(isDecisionStage\(stage\)\)/); + assert.match(html, /findingSummary\.textContent = finding\.summary/); + assert.match(html, /stageCaption\.textContent = stageCaptions\[currentStage\]/); + assert.match(html, /\.cleanup-analysis-header \{/); + assert.match(html, /\.cleanup-stage-rail \{/); + assert.match(html, /html\[data-cleanup-artifact="true"\] \.header \{/); + assert.match(html, / { + const route = shortestDirectedPath(fixture('survey.cleanup-map.json').relationships, 'entrypoint', 'publisher'); + assert.deepEqual(route.nodes, ['entrypoint', 'coordinator', 'legacyRouter', 'handler', 'publisher']); + assert.equal(route.relationships.length, 4); +}); + +test('validator rejects guessed and dangling topology', () => { + const document = fixture('survey.cleanup-map.json'); + document.relationships[0].evidence = 'suspected'; + document.findings[0].related.push('ghost'); + document.findings[0].invented_score = 0.99; + const problems = validateDocument(document).join('\n'); + assert.match(problems, /must equal "confirmed"/); + assert.match(problems, /references unknown ID "ghost"/); + assert.match(problems, /invented_score is not part of the cleanup-map contract/); +}); + +test('validator keeps visual report copy concise', () => { + const document = fixture('survey.cleanup-map.json'); + document.findings[0].summary = '冗'.repeat(181); + document.findings[0].unknowns = ['a', 'b', 'c', 'a']; + document.relationships[0].label = '过'.repeat(29); + const problems = validateDocument(document).join('\n'); + assert.match(problems, /summary must contain at most 180 characters/); + assert.match(problems, /unknowns must contain at most 3 decision-relevant items/); + assert.match(problems, /unknowns duplicates "a"/); + assert.match(problems, /label must contain at most 28 characters/); +}); + +test('validator accepts only portable evidence links', () => { + const document = fixture('survey.cleanup-map.json'); + document.nodes[0].locus.href = 'java\nscript:globalThis.compromised = true'; + document.findings[0].report_url = 'data:text/html,unsafe'; + const problems = validateDocument(document).join('\n'); + assert.match(problems, /locus\/href must not contain surrounding whitespace or control characters/); + assert.match(problems, /report_url must be an HTTPS URL or a relative\/hash link/); +}); + +test('Change receipt describes one actual cut with coherent snapshots', () => { + const document = fixture('change.cleanup-map.json'); + document.change.after.nodes.push('legacyRouter'); + document.change.after.relationships.push('legacyDelegate'); + document.change.after.nodes = document.change.after.nodes.filter((id) => id !== 'publisher'); + document.change.after.relationships = document.change.after.relationships.filter((id) => id !== 'requestDispatch'); + document.findings.push({ ...structuredClone(document.findings[0]), id: 'S2' }); + const problems = validateDocument(document).join('\n'); + assert.match(problems, /cut\/nodes retains "legacyRouter" in \/change\/after/); + assert.match(problems, /includes "resultPublish" without both endpoint nodes/); + assert.match(problems, /cut\/nodes omits removed node "publisher"/); + assert.match(problems, /cut\/relationships omits removed relationship "requestDispatch"/); + assert.match(problems, /must contain exactly one changed Finding in Change mode/); +}); + +test('Change validation reports type-wrong cut lists without throwing', () => { + const document = fixture('change.cleanup-map.json'); + document.findings[0].cut.nodes = 7; + document.findings[0].cut.relationships = {}; + const problems = validateDocument(document).join('\n'); + assert.match(problems, /cut\/nodes must be an array/); + assert.match(problems, /cut\/relationships must be an array/); +}); + +test('Survey maps visualize ranked candidates with a concrete cut', () => { + const document = fixture('survey.cleanup-map.json'); + document.findings[0].disposition = 'unresolved'; + delete document.findings[0].cut; + const problems = validateDocument(document).join('\n'); + assert.match(problems, /disposition must be ranked in Survey mode/); + assert.match(problems, /cut must describe at least one retired node or relationship/); +}); + +test('cut and route stay inside the finding boundary', () => { + const document = fixture('survey.cleanup-map.json'); + document.findings[0].related = ['entrypoint', 'coordinator', 'legacyRouter', 'publisher']; + document.findings[0].cut.relationships = ['legacyDelegate']; + const problems = validateDocument(document).join('\n'); + assert.match(problems, /related must include every node on the route/); + assert.match(problems, /cut\/relationships must include "canonicalDelegate" incident to cut node "legacyRouter"/); + + document.findings[0].cut.relationships.push('canonicalDelegate'); + assert.match(validateDocument(document).join('\n'), /related must include both endpoints of cut relationship "canonicalDelegate"/); +}); + +test('embedded report text cannot terminate the JSON script', () => { + const document = fixture('survey.cleanup-map.json'); + document.findings[0].summary = ''; + const html = renderDocument(document); + assert.doesNotMatch(html, /