dsh-knowhub 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/README.md +11 -2
  2. package/cordis.patch.yml +10 -0
  3. package/lib/host/model.js +1 -1
  4. package/lib/host/pipeline/incremental.js +3 -3
  5. package/lib/host/pipeline/initialize.js +144 -27
  6. package/lib/host/pipeline/model-output.js +2 -2
  7. package/package.json +3 -2
  8. package/skills/archify/LICENSE +202 -0
  9. package/skills/archify/LICENSE-MIT +22 -0
  10. package/skills/archify/NOTICE +26 -0
  11. package/skills/archify/SKILL.md +137 -0
  12. package/skills/archify/THIRD_PARTY_NOTICES.md +78 -0
  13. package/skills/archify/assets/JetBrainsMono-OFL.txt +93 -0
  14. package/skills/archify/assets/template.html +14768 -0
  15. package/skills/archify/bin/archify.mjs +7289 -0
  16. package/skills/archify/bin/delivery-update.mjs +92 -0
  17. package/skills/archify/bin/finalize.mjs +1055 -0
  18. package/skills/archify/bin/open-artifact.mjs +146 -0
  19. package/skills/archify/bin/preview.mjs +970 -0
  20. package/skills/archify/bin/recover-output.mjs +41 -0
  21. package/skills/archify/bin/visual-check.mjs +2780 -0
  22. package/skills/archify/brand-marks/README.md +31 -0
  23. package/skills/archify/brand-marks/catalog.json +131 -0
  24. package/skills/archify/delta/architecture-delta.mjs +1318 -0
  25. package/skills/archify/examples/agent-run.lifecycle.json +55 -0
  26. package/skills/archify/examples/agent-tool-call.workflow.json +74 -0
  27. package/skills/archify/examples/archify-dev-activity.timeline.json +176 -0
  28. package/skills/archify/examples/archify-repository.tree.json +197 -0
  29. package/skills/archify/examples/async-job-roundtrip.sequence.json +56 -0
  30. package/skills/archify/examples/brand-aware-delivery.architecture.json +34 -0
  31. package/skills/archify/examples/cache-miss-request.sequence.json +77 -0
  32. package/skills/archify/examples/checkout-platform.base.architecture.json +32 -0
  33. package/skills/archify/examples/checkout-platform.head.architecture.json +32 -0
  34. package/skills/archify/examples/checkout-request.waterfall.json +20 -0
  35. package/skills/archify/examples/delivery.explainer.json +155 -0
  36. package/skills/archify/examples/deployment-release.lifecycle.json +72 -0
  37. package/skills/archify/examples/event-stream.dataflow.json +52 -0
  38. package/skills/archify/examples/example-rebuild.waterfall.json +212 -0
  39. package/skills/archify/examples/holiday-planning.workflow.json +143 -0
  40. package/skills/archify/examples/incident-response.workflow.json +59 -0
  41. package/skills/archify/examples/locales/de.json +463 -0
  42. package/skills/archify/examples/locales/fr.json +463 -0
  43. package/skills/archify/examples/locales/fr.partial.json +22 -0
  44. package/skills/archify/examples/locales/it.json +463 -0
  45. package/skills/archify/examples/locales/ja.json +463 -0
  46. package/skills/archify/examples/locales/pt.json +463 -0
  47. package/skills/archify/examples/locales/ru.json +463 -0
  48. package/skills/archify/examples/orders.erd.json +337 -0
  49. package/skills/archify/examples/payment-incident.timeline.json +24 -0
  50. package/skills/archify/examples/payment-platform.tree.json +22 -0
  51. package/skills/archify/examples/payment-processors.class.json +177 -0
  52. package/skills/archify/examples/payments.class.json +85 -0
  53. package/skills/archify/examples/product-analytics.dataflow.json +71 -0
  54. package/skills/archify/examples/production-deployment.architecture.json +51 -0
  55. package/skills/archify/examples/release-delivery.workflow.json +57 -0
  56. package/skills/archify/examples/subscription-billing.erd.json +548 -0
  57. package/skills/archify/examples/web-app.architecture.json +41 -0
  58. package/skills/archify/explainer/browser-check.mjs +208 -0
  59. package/skills/archify/explainer/charts.mjs +70 -0
  60. package/skills/archify/explainer/check.mjs +167 -0
  61. package/skills/archify/explainer/diagrams.mjs +517 -0
  62. package/skills/archify/explainer/diff.mjs +124 -0
  63. package/skills/archify/explainer/inline.mjs +155 -0
  64. package/skills/archify/explainer/layout.mjs +35 -0
  65. package/skills/archify/explainer/page-bundle.mjs +16 -0
  66. package/skills/archify/explainer/parse.mjs +300 -0
  67. package/skills/archify/explainer/patch.mjs +117 -0
  68. package/skills/archify/explainer/prepare.mjs +131 -0
  69. package/skills/archify/explainer/reader.css +472 -0
  70. package/skills/archify/explainer/reader.js +666 -0
  71. package/skills/archify/explainer/render.mjs +224 -0
  72. package/skills/archify/explainer/revision.mjs +20 -0
  73. package/skills/archify/explainer/sources.mjs +318 -0
  74. package/skills/archify/explainer/validate.mjs +138 -0
  75. package/skills/archify/explainer/vendor/pele/LICENSE +40 -0
  76. package/skills/archify/explainer/vendor/pele/README.md +5 -0
  77. package/skills/archify/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
  78. package/skills/archify/explainer/vendor/pele/chunks/42UTT654.js +3 -0
  79. package/skills/archify/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
  80. package/skills/archify/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
  81. package/skills/archify/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
  82. package/skills/archify/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
  83. package/skills/archify/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
  84. package/skills/archify/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
  85. package/skills/archify/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
  86. package/skills/archify/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
  87. package/skills/archify/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
  88. package/skills/archify/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
  89. package/skills/archify/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
  90. package/skills/archify/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
  91. package/skills/archify/explainer/vendor/pele/core.js +1 -0
  92. package/skills/archify/explainer/vendor/pele/diagrams/pie.js +1 -0
  93. package/skills/archify/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
  94. package/skills/archify/explainer/vendor/pele/diagrams/radar.js +1 -0
  95. package/skills/archify/explainer/vendor/pele/diagrams/sankey.js +1 -0
  96. package/skills/archify/explainer/vendor/pele/diagrams/venn.js +1 -0
  97. package/skills/archify/explainer/vendor/pele/diagrams/xychart.js +1 -0
  98. package/skills/archify/explainer/writing.mjs +139 -0
  99. package/skills/archify/locales/README.md +40 -0
  100. package/skills/archify/locales/ar.json +488 -0
  101. package/skills/archify/locales/en.json +488 -0
  102. package/skills/archify/locales/es.json +488 -0
  103. package/skills/archify/locales/ko.json +488 -0
  104. package/skills/archify/locales/manifest.json +10 -0
  105. package/skills/archify/locales/zh-CN.json +488 -0
  106. package/skills/archify/locales/zh-TW.json +488 -0
  107. package/skills/archify/migrations/workflow-v2.mjs +280 -0
  108. package/skills/archify/package.json +17 -0
  109. package/skills/archify/recipes/scenarios.mjs +455 -0
  110. package/skills/archify/references/architecture-layout-repair.md +18 -0
  111. package/skills/archify/references/authoring-contract.md +645 -0
  112. package/skills/archify/references/authoring-defaults.md +57 -0
  113. package/skills/archify/references/brand-marks.md +119 -0
  114. package/skills/archify/references/delivery-contract.md +665 -0
  115. package/skills/archify/references/explainer-authoring.md +274 -0
  116. package/skills/archify/references/repository-authoring.md +126 -0
  117. package/skills/archify/references/update-awareness.md +13 -0
  118. package/skills/archify/references/viewer-runtime.md +52 -0
  119. package/skills/archify/renderers/architecture/grid.mjs +62 -0
  120. package/skills/archify/renderers/architecture/labels.mjs +2 -0
  121. package/skills/archify/renderers/architecture/render-architecture.mjs +1204 -0
  122. package/skills/archify/renderers/architecture/routing.mjs +1469 -0
  123. package/skills/archify/renderers/class/README.md +130 -0
  124. package/skills/archify/renderers/class/render-class.mjs +1095 -0
  125. package/skills/archify/renderers/dataflow/README.md +176 -0
  126. package/skills/archify/renderers/dataflow/dataflow-compiler.mjs +1605 -0
  127. package/skills/archify/renderers/dataflow/joint-label-routing.mjs +165 -0
  128. package/skills/archify/renderers/dataflow/label-capacity.mjs +81 -0
  129. package/skills/archify/renderers/dataflow/render-dataflow.mjs +23 -0
  130. package/skills/archify/renderers/erd/README.md +246 -0
  131. package/skills/archify/renderers/erd/grid.mjs +150 -0
  132. package/skills/archify/renderers/erd/render-erd.mjs +1588 -0
  133. package/skills/archify/renderers/explainer/render-explainer.mjs +42 -0
  134. package/skills/archify/renderers/lifecycle/README.md +104 -0
  135. package/skills/archify/renderers/lifecycle/render-lifecycle.mjs +1134 -0
  136. package/skills/archify/renderers/sequence/README.md +146 -0
  137. package/skills/archify/renderers/sequence/render-sequence.mjs +1105 -0
  138. package/skills/archify/renderers/shared/atomic-output.mjs +1982 -0
  139. package/skills/archify/renderers/shared/automatic-labels.mjs +290 -0
  140. package/skills/archify/renderers/shared/brand-marks.mjs +706 -0
  141. package/skills/archify/renderers/shared/cli.mjs +430 -0
  142. package/skills/archify/renderers/shared/desktop-readability.mjs +132 -0
  143. package/skills/archify/renderers/shared/diagnostics.mjs +0 -0
  144. package/skills/archify/renderers/shared/engineering-profiles.mjs +157 -0
  145. package/skills/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  146. package/skills/archify/renderers/shared/generated-validators.mjs +279 -0
  147. package/skills/archify/renderers/shared/geometry.mjs +2073 -0
  148. package/skills/archify/renderers/shared/i18n.mjs +328 -0
  149. package/skills/archify/renderers/shared/layout-report.mjs +57 -0
  150. package/skills/archify/renderers/shared/legend.mjs +227 -0
  151. package/skills/archify/renderers/shared/node-card-style.mjs +32 -0
  152. package/skills/archify/renderers/shared/output-path.mjs +440 -0
  153. package/skills/archify/renderers/shared/owned-temp-directory.mjs +82 -0
  154. package/skills/archify/renderers/shared/path-semantics.mjs +1017 -0
  155. package/skills/archify/renderers/shared/portable-path.mjs +408 -0
  156. package/skills/archify/renderers/shared/repository-evidence.mjs +394 -0
  157. package/skills/archify/renderers/shared/repository-location.mjs +71 -0
  158. package/skills/archify/renderers/shared/route-quality.mjs +670 -0
  159. package/skills/archify/renderers/shared/sidecar-path.mjs +38 -0
  160. package/skills/archify/renderers/shared/software-notices.mjs +6 -0
  161. package/skills/archify/renderers/shared/spatial-grid.mjs +66 -0
  162. package/skills/archify/renderers/shared/svg-path-analysis.mjs +135 -0
  163. package/skills/archify/renderers/shared/text-fit.mjs +105 -0
  164. package/skills/archify/renderers/shared/utils.mjs +260 -0
  165. package/skills/archify/renderers/shared/validator.mjs +83 -0
  166. package/skills/archify/renderers/timeline/README.md +76 -0
  167. package/skills/archify/renderers/timeline/render-timeline.mjs +576 -0
  168. package/skills/archify/renderers/tree/README.md +96 -0
  169. package/skills/archify/renderers/tree/render-tree.mjs +573 -0
  170. package/skills/archify/renderers/waterfall/README.md +73 -0
  171. package/skills/archify/renderers/waterfall/render-waterfall.mjs +413 -0
  172. package/skills/archify/renderers/workflow/README.md +337 -0
  173. package/skills/archify/renderers/workflow/render-workflow.mjs +47 -0
  174. package/skills/archify/renderers/workflow/workflow-compiler.mjs +5920 -0
  175. package/skills/archify/renderers/workflow/workflow-migration-geometry.mjs +161 -0
  176. package/skills/archify/renderers/workflow/workflow-placement-repair.mjs +94 -0
  177. package/skills/archify/schemas/README.md +258 -0
  178. package/skills/archify/schemas/architecture.schema.json +152 -0
  179. package/skills/archify/schemas/class.schema.json +345 -0
  180. package/skills/archify/schemas/common.schema.json +181 -0
  181. package/skills/archify/schemas/dataflow.schema.json +254 -0
  182. package/skills/archify/schemas/erd.schema.json +381 -0
  183. package/skills/archify/schemas/explainer.schema.json +927 -0
  184. package/skills/archify/schemas/lifecycle.schema.json +179 -0
  185. package/skills/archify/schemas/sequence.schema.json +234 -0
  186. package/skills/archify/schemas/timeline.schema.json +160 -0
  187. package/skills/archify/schemas/tree.schema.json +141 -0
  188. package/skills/archify/schemas/waterfall.schema.json +147 -0
  189. package/skills/archify/schemas/workflow.schema.json +439 -0
  190. package/skills/archify/scripts/check-render-output.mjs +1469 -0
  191. package/skills/archify/scripts/check-update.mjs +1717 -0
  192. package/skills/archify/scripts/delivery-update-child.mjs +26 -0
  193. package/skills/archify/scripts/render-examples.mjs +36 -0
  194. package/skills/archify/scripts/update-contract.mjs +187 -0
  195. package/skills/archify/skill-release.json +10 -0
@@ -0,0 +1,1469 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import { isExplainerHtml, checkExplainerHtml } from '../explainer/check.mjs';
5
+ import { analyzeSvgPath } from '../renderers/shared/svg-path-analysis.mjs';
6
+ import { textUnits } from '../renderers/shared/utils.mjs';
7
+ import { createHash } from 'node:crypto';
8
+ import path from 'node:path';
9
+ import { collectAmbiguousCorridors, collectArrowheadCollisions, collectBorderRuns, collectLabelCanvasOverflow, collectLabelRouteClearance, collectRouteRhythmIssues, describeLabelCanvasOverflow, formatRect, forwardCollinearAnalysisSegments, minimumLabelRouteClearance, normalizeRoutePoints, routeBudgetMetrics } from '../renderers/shared/geometry.mjs';
10
+ import {
11
+ DESKTOP_READABILITY_VIEWPORT,
12
+ DESKTOP_READER_DIAGRAM_WIDTH,
13
+ DECLARED_WIDE_READER_CONTRACT,
14
+ declaredWideReadabilityBudget,
15
+ MIN_PROJECTED_NODE_TEXT_PX,
16
+ describeFixedWidthOverflow,
17
+ predictedFixedWidthOverflow,
18
+ projectedNodeTextPx,
19
+ } from '../renderers/shared/desktop-readability.mjs';
20
+
21
+ const input = process.argv[2];
22
+
23
+ if (!input || input === '-h' || input === '--help') {
24
+ console.error('Usage: node scripts/check-render-output.mjs <diagram.html>');
25
+ process.exit(input ? 0 : 2);
26
+ }
27
+
28
+ const htmlPath = path.resolve(input);
29
+ let html;
30
+ let artifact;
31
+ try {
32
+ const bytes = fs.readFileSync(htmlPath);
33
+ html = bytes.toString('utf8');
34
+ artifact = { sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.byteLength };
35
+ } catch (err) {
36
+ console.error(JSON.stringify({
37
+ ok: false,
38
+ file: htmlPath,
39
+ checks: [{ name: 'file_readable', ok: false, details: [err.message] }],
40
+ }, null, 2));
41
+ process.exit(1);
42
+ }
43
+
44
+ if (isExplainerHtml(html)) {
45
+ const result = checkExplainerHtml(html, { file: htmlPath });
46
+ console.log(JSON.stringify(result, null, 2));
47
+ process.exit(result.ok ? 0 : 1);
48
+ }
49
+
50
+ const checks = [];
51
+ const pathDiagnostics = [];
52
+ const pathAnalyses = new Map();
53
+ let composition = {
54
+ schemaVersion: 1,
55
+ profile: 'standard',
56
+ status: 'pass',
57
+ summary: { errors: 0, warnings: 0 },
58
+ metrics: {
59
+ properCrossings: 0,
60
+ resolvedCrossovers: 0,
61
+ ambiguousCorridors: 0,
62
+ containerBorderRuns: 0,
63
+ labelRouteClearanceIssues: 0,
64
+ minLabelRouteClearance: null,
65
+ labelCanvasOverflowIssues: 0,
66
+ maxBends: 0,
67
+ routesOverSuggestedBends: 0,
68
+ maxStretch: null,
69
+ routesOverSuggestedStretch: 0,
70
+ minSegmentPx: null,
71
+ minInteriorSegmentPx: null,
72
+ shortSegmentCount: 0,
73
+ shortEndpointSegmentCount: 0,
74
+ shortInteriorSegmentCount: 0,
75
+ microSegmentCount: 0,
76
+ desktopReadabilityIssues: 0,
77
+ minProjectedNodeTextPx: null,
78
+ },
79
+ suggestedLimits: { bendsPerRelationship: 2, stretch: 1.35, segmentPx: 16, microSegmentPx: 8 },
80
+ issues: [],
81
+ };
82
+
83
+ const NON_FINITE_TOKEN = /\b(?:NaN|undefined|Infinity)\b/;
84
+ // Consume comments/CDATA as whole tokens, including any tag-like prose.
85
+ const SVG_TAG_TOKEN = /<!--[\s\S]*?(?:-->|$)|<!\[CDATA\[[\s\S]*?(?:\]\]>|$)|<(\/?)([A-Za-z][\w:-]*)(?=[\s/>])(?:[^>"']|"[^"]*"|'[^']*')*>/g;
86
+ const AUTOMATIC_CROSSOVER_UNDERLAY_TAG = /<path\b[^>]*\bdata-graph-role="automatic-crossover-underlay"[^>]*\/>/i;
87
+ const HTML_VOID_ELEMENTS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
88
+ const SVG_HTML_INTEGRATION_POINTS = new Set(['foreignobject', 'desc', 'title']);
89
+ const HTML_ATTRIBUTE = /([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g;
90
+ const readerContract = readerContractFromHtml(html);
91
+ const NUMERIC_ATTRS = new Set([
92
+ 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'dx', 'dy', 'cx', 'cy', 'r', 'rx', 'ry', 'fx', 'fy',
93
+ 'fr', 'width', 'height', 'd', 'points', 'pathlength', 'transform', 'viewbox', 'offset',
94
+ 'opacity', 'fill-opacity', 'flood-opacity', 'stop-opacity', 'stroke-opacity', 'font-size',
95
+ 'font-size-adjust', 'font-weight', 'letter-spacing', 'word-spacing', 'kerning', 'stroke-width',
96
+ 'stroke-dasharray', 'stroke-dashoffset', 'stroke-miterlimit', 'textlength', 'startoffset',
97
+ 'rotate', 'markerwidth', 'markerheight', 'refx', 'refy', 'orient', 'patterntransform',
98
+ 'gradienttransform', 'filterres', 'stddeviation', 'basefrequency', 'numoctaves', 'seed',
99
+ 'surfacescale', 'diffuseconstant', 'specularconstant', 'specularexponent',
100
+ 'limitingconeangle', 'azimuth', 'elevation', 'pointsatx', 'pointsaty', 'pointsatz',
101
+ 'kernelmatrix', 'order', 'divisor', 'bias', 'targetx', 'targety', 'kernelunitlength',
102
+ 'scale', 'radius', 'k1', 'k2', 'k3', 'k4', 'tablevalues', 'slope', 'intercept',
103
+ 'amplitude', 'exponent',
104
+ ]);
105
+ const ELEMENT_NUMERIC_ATTRS = new Map([
106
+ ['fecolormatrix', new Set(['values'])],
107
+ ['fepointlight', new Set(['z'])],
108
+ ['fespotlight', new Set(['z'])],
109
+ ]);
110
+
111
+ function addCheck(name, ok, details = []) {
112
+ checks.push({ name, ok, details });
113
+ }
114
+
115
+ const svgMatches = [...html.matchAll(/<svg\b[\s\S]*?<\/svg>/gi)];
116
+ addCheck('single_svg', svgMatches.length === 1, [`found ${svgMatches.length} <svg> block(s)`]);
117
+
118
+ if (svgMatches.length === 1) {
119
+ const svg = svgMatches[0][0];
120
+ const svgRoot = svg.match(/<svg\b[^>]*>/i)?.[0] || '';
121
+ const svgAttrs = parseAttrs(svgRoot);
122
+ const qualityProfile = svgAttrs['data-quality-profile'] || 'standard';
123
+ const workflowV2 = svgAttrs['data-layout-contract'] === 'readable-v2';
124
+ const qualityGatesEnforced = svgAttrs['data-quality-gates'] !== 'advisory';
125
+ const nonFiniteAttrs = collectNonFiniteAttrs(svg);
126
+ addCheck('finite_svg', nonFiniteAttrs.length === 0, nonFiniteAttrs);
127
+ const legendStart = svg.indexOf('<!-- Legend -->');
128
+ const beforeLegend = legendStart >= 0 ? svg.slice(0, legendStart) : svg;
129
+ const desktopReadability = collectDesktopReadability(svgAttrs, beforeLegend, readerContract);
130
+ const desktopReadabilityIssue = desktopReadability.issue;
131
+ // The browser gate measures this later; the geometry is already certain here.
132
+ const viewportHeightIssue = predictedFixedWidthOverflow({
133
+ viewBoxWidth: viewBoxSize(svgAttrs)[0],
134
+ viewBoxHeight: viewBoxSize(svgAttrs)[1],
135
+ readerFit: svgAttrs['data-reader-fit'] || null,
136
+ diagramType: svgAttrs['data-diagram-type'] || null,
137
+ });
138
+ const arrows = collectArrows(beforeLegend, workflowV2);
139
+ const diagonal = arrows.flatMap((arrow) => diagonalStraightSegments(arrow).map((segment) => ({ arrow, ...segment })));
140
+ addCheck(
141
+ 'orthogonal_arrows',
142
+ diagonal.length === 0,
143
+ diagonal.map(({ arrow, segmentIndex }) => `${arrow.kind} ${arrow.index} segment ${segmentIndex + 1}: expected an orthogonal segment or an explicitly authored direct straight route; ${arrow.raw}`),
144
+ );
145
+ const measuredRelationshipCrossings = collectRelationshipCrossings(arrows, workflowV2);
146
+ const resolvedCrossovers = measuredRelationshipCrossings.filter((hit) => (
147
+ hit.left.crossoverHalo && hit.right.crossoverHalo
148
+ ));
149
+ const relationshipCrossings = measuredRelationshipCrossings.filter((hit) => (
150
+ !hit.left.crossoverHalo || !hit.right.crossoverHalo
151
+ ));
152
+ const compositionFrames = collectCompositionFrames(beforeLegend);
153
+ const containerBorderRuns = collectBorderRuns({
154
+ routedRelations: arrows
155
+ .filter((arrow) => arrow.from && arrow.to && arrow.borderSegments.length)
156
+ .map((arrow) => ({
157
+ relation: arrow,
158
+ relationIndex: arrow.index,
159
+ segments: arrow.borderSegments,
160
+ })),
161
+ frames: compositionFrames,
162
+ });
163
+ const routedRelationships = arrows
164
+ .filter((arrow) => arrow.from && arrow.to && arrow.routeSubpaths.some(points => points.length > 1))
165
+ .map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints }));
166
+ const nodeRects = svgAttrs.transform ? [] : collectUntransformedNodeRects(beforeLegend);
167
+ const routeMetrics = subpathBudgetMetrics(routedRelationships);
168
+ // Architecture marks only one of its two Reader fits with its type.
169
+ const crowdedSides = svgAttrs['data-diagram-type'] === 'architecture' || svgAttrs['data-reader-primary-text'] === '14'
170
+ ? crowdedNodeSides(arrows, nodeRects) : [];
171
+ const routeRhythmIssues = subpathRhythmIssues(routedRelationships);
172
+ const ambiguousCorridors = subpathCorridors({
173
+ routedRelations: routedRelationships,
174
+ includeSharedEndpoints: (left, right) => workflowV2 || (left.independentPorts && right.independentPorts),
175
+ allowShortWorkflowTrunks: workflowV2,
176
+ includeSharedEndpointCounterflow: (left, right) => left.automaticWorkflowRoute && right.automaticWorkflowRoute,
177
+ });
178
+ const arrowheadCollisions = collectArrowheadCollisions({
179
+ routedRelations: routedRelationships.filter((entry) => workflowV2 || entry.relation.independentPorts)
180
+ .flatMap(entry => routeParts(entry).filter(part => part.targetEndpoint)),
181
+ allowShortWorkflowTrunks: workflowV2,
182
+ });
183
+ const relationshipLabels = collectRelationshipLabelMasks(beforeLegend, arrows);
184
+ const leadingSpace = collectArchitectureLeadingSpace({
185
+ svgAttrs, fragment: beforeLegend, nodeRects, frames: compositionFrames,
186
+ arrows, labels: relationshipLabels,
187
+ });
188
+ const sequenceColumnSpace = collectSequenceColumnSpace({ svgAttrs, fragment: beforeLegend, nodeRects, arrows });
189
+ const sequenceNoteBorders = collectSequenceNoteBorders({ svgAttrs, fragment: beforeLegend, frames: compositionFrames, arrows });
190
+ const labelClearanceThreshold = qualityProfile === 'showcase' ? 4 : 2;
191
+ const labelRouteMeasurements = subpathLabelClearance({
192
+ labels: relationshipLabels,
193
+ routedRelations: arrows.map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints })),
194
+ threshold: Number.MAX_VALUE,
195
+ });
196
+ const labelRouteClearance = subpathLabelClearance({
197
+ labels: relationshipLabels,
198
+ routedRelations: arrows.map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints })),
199
+ threshold: labelClearanceThreshold,
200
+ });
201
+ // The renderers bound their own label rects, but `check` also re-measures an
202
+ // artifact it did not produce; see collectLabelCanvasOverflow in
203
+ // shared/geometry.mjs.
204
+ const labelCanvasOverflow = collectLabelCanvasOverflow({
205
+ labels: relationshipLabels,
206
+ viewBox: viewBoxRect(svgAttrs),
207
+ });
208
+ const crossingIsError = qualityProfile === 'showcase';
209
+ const corridorIsError = qualityProfile === 'showcase';
210
+ const rhythmIsError = qualityProfile === 'showcase';
211
+ const labelClearanceIsError = qualityProfile === 'showcase';
212
+ const labelContainmentIsError = qualityProfile === 'showcase';
213
+ const desktopReadabilityIsError = qualityProfile === 'showcase';
214
+ // Certain geometry, but new: surface it as evidence first (CONTRIBUTING.md#product-and-compatibility-contracts).
215
+ const viewportHeightIsError = false;
216
+ const compositionErrors = (qualityGatesEnforced ? containerBorderRuns.length : 0)
217
+ + (crossingIsError ? relationshipCrossings.length : 0)
218
+ + (corridorIsError ? ambiguousCorridors.length + arrowheadCollisions.length : 0)
219
+ + (labelClearanceIsError ? labelRouteClearance.length : 0)
220
+ + (labelContainmentIsError ? labelCanvasOverflow.length : 0)
221
+ + (rhythmIsError ? routeRhythmIssues.length : 0)
222
+ + (desktopReadabilityIsError && desktopReadabilityIssue ? 1 : 0)
223
+ + (viewportHeightIsError && viewportHeightIssue ? 1 : 0);
224
+ const compositionWarnings = (qualityGatesEnforced ? 0 : containerBorderRuns.length)
225
+ + (crossingIsError ? 0 : relationshipCrossings.length)
226
+ + (corridorIsError ? 0 : ambiguousCorridors.length + arrowheadCollisions.length)
227
+ + (labelClearanceIsError ? 0 : labelRouteClearance.length)
228
+ + (labelContainmentIsError ? 0 : labelCanvasOverflow.length)
229
+ + (rhythmIsError ? 0 : routeRhythmIssues.length)
230
+ + (desktopReadabilityIsError || !desktopReadabilityIssue ? 0 : 1)
231
+ + (viewportHeightIsError || !viewportHeightIssue ? 0 : 1);
232
+ composition = {
233
+ schemaVersion: 1,
234
+ profile: qualityProfile,
235
+ status: compositionErrors ? 'fail' : 'pass',
236
+ summary: {
237
+ errors: compositionErrors,
238
+ warnings: compositionWarnings,
239
+ },
240
+ metrics: {
241
+ properCrossings: relationshipCrossings.length,
242
+ resolvedCrossovers: resolvedCrossovers.length,
243
+ ambiguousCorridors: ambiguousCorridors.length,
244
+ arrowheadCollisions: arrowheadCollisions.length,
245
+ containerBorderRuns: containerBorderRuns.length,
246
+ labelRouteClearanceIssues: labelRouteClearance.length,
247
+ labelCanvasOverflowIssues: labelCanvasOverflow.length,
248
+ minLabelRouteClearance: minimumLabelRouteClearance(labelRouteMeasurements),
249
+ desktopReadabilityIssues: desktopReadabilityIssue ? 1 : 0,
250
+ viewportHeightIssues: viewportHeightIssue ? 1 : 0,
251
+ minProjectedNodeTextPx: desktopReadability.evidence.minimumProjectedTextPx,
252
+ ...roundedRouteMetrics(routeMetrics),
253
+ },
254
+ suggestedLimits: { bendsPerRelationship: 2, stretch: 1.35, segmentPx: 16, microSegmentPx: 8 },
255
+ // Perceptual review needs the affected relationships, not just a total.
256
+ // These are review evidence, not new pass/fail thresholds: a short, clear
257
+ // crossover can be preferable to a long crossing-free detour.
258
+ routeReview: {
259
+ crossings: resolvedCrossovers.map((hit) => {
260
+ const shared = [hit.left.from, hit.left.to].find((id) => id && (id === hit.right.from || id === hit.right.to));
261
+ return {
262
+ left: relationshipRecord(hit.left),
263
+ right: relationshipRecord(hit.right),
264
+ point: hit.point,
265
+ ...(shared ? { sharedNode: shared } : {}),
266
+ };
267
+ }),
268
+ ...(crowdedSides.length ? { crowdedSides } : {}),
269
+ detours: routedRelationships.flatMap((entry) => {
270
+ const metrics = subpathBudgetMetrics([entry]);
271
+ const blockers = directCorridorBlockers(entry.relation, nodeRects);
272
+ return metrics.routesOverSuggestedBends || metrics.routesOverSuggestedStretch ? [{
273
+ relationship: relationshipRecord(entry.relation),
274
+ bends: metrics.maxBends,
275
+ stretch: metrics.maxStretch == null ? null : Math.round(metrics.maxStretch * 1000) / 1000,
276
+ ...(blockers.length ? { directCorridorBlockers: blockers } : {}),
277
+ }] : [];
278
+ }),
279
+ },
280
+ leadingSpace,
281
+ ...(sequenceColumnSpace ? { sequenceColumnSpace } : {}),
282
+ ...(sequenceNoteBorders.length ? { sequenceNoteBorders } : {}),
283
+ desktopReadability: desktopReadability.evidence,
284
+ issues: [
285
+ ...containerBorderRuns.map((hit) => ({
286
+ severity: qualityGatesEnforced ? 'error' : 'warning',
287
+ code: 'composition/container-border-run',
288
+ relationship: relationshipRecord(hit.relation),
289
+ frame: frameRecord(hit.frame),
290
+ side: hit.side,
291
+ segmentIndex: hit.segmentIndex,
292
+ overlapLength: Math.round(hit.overlapLength * 10) / 10,
293
+ from: hit.overlapStart.map((value) => Math.round(value * 10) / 10),
294
+ to: hit.overlapEnd.map((value) => Math.round(value * 10) / 10),
295
+ })),
296
+ ...labelRouteClearance.map((hit) => ({
297
+ severity: labelClearanceIsError ? 'error' : 'warning',
298
+ code: 'composition/label-route-clearance',
299
+ label: hit.label?.label || hit.labelRelation?.label || '',
300
+ labelRelationship: relationshipRecord(hit.labelRelation),
301
+ otherRelationship: relationshipRecord(hit.otherRelation),
302
+ segmentIndex: hit.segmentIndex,
303
+ labelRect: roundedRect(hit.rect),
304
+ clearance: Math.round(hit.clearance * 10) / 10,
305
+ intersectionLength: Math.round((hit.intersectionLength || 0) * 10) / 10,
306
+ threshold: hit.threshold,
307
+ from: hit.start.map((value) => Math.round(value * 10) / 10),
308
+ to: hit.end.map((value) => Math.round(value * 10) / 10),
309
+ })),
310
+ ...labelCanvasOverflow.map((hit) => {
311
+ const label = hit.label?.label || hit.relation?.label || '';
312
+ return {
313
+ severity: labelContainmentIsError ? 'error' : 'warning',
314
+ code: 'composition/label-canvas-containment',
315
+ label,
316
+ relationship: relationshipRecord(hit.relation),
317
+ labelRect: roundedRect(hit.rect),
318
+ viewBox: hit.viewBox,
319
+ viewBoxOrigin: hit.viewBoxOrigin,
320
+ overflowPx: hit.overflowPx,
321
+ detail: `[composition/label-canvas-containment] ${qualityProfile} label "${label}" on ${relationshipName(hit.relation)} extends past the ${describeLabelCanvasOverflow(hit)} (label rect ${formatRect(hit.rect)}; viewBox ${hit.viewBox[0]}x${hit.viewBox[1]}${hit.viewBoxOrigin.some(Boolean) ? ` at ${hit.viewBoxOrigin[0]},${hit.viewBoxOrigin[1]}` : ''}) — use renderer-supported label controls (shorten the label or reorder participants for sequence; otherwise labelAt, labelDx, labelDy, or labelSegment), or enlarge meta.viewBox.`,
322
+ };
323
+ }),
324
+ ...relationshipCrossings.map((hit) => ({
325
+ severity: crossingIsError ? 'error' : 'warning',
326
+ code: 'composition/proper-crossing',
327
+ relationship: relationshipRecord(hit.left),
328
+ otherRelationship: relationshipRecord(hit.right),
329
+ point: hit.point.map((value) => Math.round(value * 10) / 10),
330
+ })),
331
+ ...ambiguousCorridors.map((hit) => ({
332
+ severity: corridorIsError ? 'error' : 'warning',
333
+ code: 'composition/ambiguous-corridor',
334
+ relationship: relationshipRecord(hit.left.relation),
335
+ otherRelationship: relationshipRecord(hit.right.relation),
336
+ segmentIndex: hit.leftSegment,
337
+ otherSegmentIndex: hit.rightSegment,
338
+ overlapLength: Math.round(hit.overlapLength * 10) / 10,
339
+ from: hit.overlapStart.map((value) => Math.round(value * 10) / 10),
340
+ to: hit.overlapEnd.map((value) => Math.round(value * 10) / 10),
341
+ })),
342
+ ...arrowheadCollisions.map((hit) => ({
343
+ severity: corridorIsError ? 'error' : 'warning',
344
+ code: 'composition/arrowhead-collision',
345
+ relationship: relationshipRecord(hit.left.relation),
346
+ otherRelationship: relationshipRecord(hit.right.relation),
347
+ distancePx: hit.distance,
348
+ minimumPx: hit.minimum,
349
+ endpoints: [hit.left.tip, hit.right.tip],
350
+ })),
351
+ ...routeRhythmIssues.map((hit) => ({
352
+ severity: rhythmIsError ? 'error' : 'warning',
353
+ code: hit.code,
354
+ relationship: relationshipRecord(hit.relation),
355
+ segmentIndex: hit.segmentIndex,
356
+ position: hit.position,
357
+ length: Math.round(hit.length * 10) / 10,
358
+ from: hit.start.map((value) => Math.round(value * 10) / 10),
359
+ to: hit.end.map((value) => Math.round(value * 10) / 10),
360
+ })),
361
+ ...(desktopReadabilityIssue ? [{
362
+ severity: desktopReadabilityIsError ? 'error' : 'warning',
363
+ code: 'composition/desktop-readability',
364
+ ...(desktopReadabilityIssue.nodeId ? { nodeId: desktopReadabilityIssue.nodeId } : {}),
365
+ owner: desktopReadabilityIssue.owner,
366
+ viewportWidth: DESKTOP_READABILITY_VIEWPORT.width,
367
+ viewportHeight: DESKTOP_READABILITY_VIEWPORT.height,
368
+ availableDiagramWidth: desktopReadability.evidence.availableDiagramWidth,
369
+ budgetBasis: desktopReadability.evidence.budgetBasis,
370
+ readerContract: desktopReadability.evidence.readerContract,
371
+ requestedTargetPx: desktopReadability.evidence.requestedTargetPx,
372
+ requestedTargetMet: desktopReadability.evidence.requestedTargetMet,
373
+ budgetLimit: desktopReadability.evidence.limit,
374
+ viewBoxWidth: desktopReadabilityIssue.viewBoxWidth,
375
+ scale: desktopReadabilityIssue.scale,
376
+ text: desktopReadabilityIssue.text,
377
+ detail: desktopReadabilityIssue.detail,
378
+ sourceFontPx: desktopReadabilityIssue.sourceFontPx,
379
+ projectedFontPx: desktopReadabilityIssue.projectedFontPx,
380
+ minimumProjectedFontPx: MIN_PROJECTED_NODE_TEXT_PX,
381
+ }] : []),
382
+ ...(viewportHeightIssue ? [{
383
+ severity: viewportHeightIsError ? 'error' : 'warning',
384
+ code: 'composition/viewport-height',
385
+ readerFit: svgAttrs['data-reader-fit'] || null,
386
+ viewBoxWidth: viewBoxSize(svgAttrs)[0],
387
+ viewBoxHeight: viewBoxSize(svgAttrs)[1],
388
+ ...viewportHeightIssue,
389
+ detail: `[composition/viewport-height] ${describeFixedWidthOverflow({ ...viewportHeightIssue, viewBoxWidth: viewBoxSize(svgAttrs)[0], viewBoxHeight: viewBoxSize(svgAttrs)[1] })}`,
390
+ }] : []),
391
+ ],
392
+ };
393
+ addCheck(
394
+ 'label_route_clearance',
395
+ !labelClearanceIsError || labelRouteClearance.length === 0,
396
+ labelRouteClearance.map((hit) => (
397
+ `[composition/label-route-clearance] ${qualityProfile} label "${hit.label?.label || hit.labelRelation?.label || ''}" on ${relationshipName(hit.labelRelation)} is ${Math.round(hit.clearance * 10) / 10}px from ${relationshipName(hit.otherRelation)} segment ${hit.segmentIndex} [${formatPoint(hit.start)}] -> [${formatPoint(hit.end)}]${hit.intersectionLength > 0 ? ` with ${Math.round(hit.intersectionLength * 10) / 10}px hidden by the mask` : ''} (minimum ${hit.threshold}px) — use renderer-supported label controls (message y for sequence; otherwise labelAt, labelDx, labelDy, or labelSegment), or adjust the other relationship route/via/channel.`
398
+ )),
399
+ );
400
+ addCheck(
401
+ 'relationship_crossings',
402
+ !crossingIsError || relationshipCrossings.length === 0,
403
+ relationshipCrossings.map((hit) => (
404
+ `[composition/proper-crossing] ${qualityProfile} ${relationshipName(hit.left)} crosses ${relationshipName(hit.right)} at [${formatPoint(hit.point)}]`
405
+ )),
406
+ );
407
+ addCheck(
408
+ 'relationship_corridors',
409
+ !corridorIsError || ambiguousCorridors.length + arrowheadCollisions.length === 0,
410
+ [...ambiguousCorridors.map((hit) => (
411
+ `[composition/ambiguous-corridor] ${qualityProfile} ${relationshipName(hit.left.relation)} shares a ${Math.round(hit.overlapLength * 10) / 10}px corridor with ${relationshipName(hit.right.relation)} at [${formatPoint(hit.overlapStart)}] -> [${formatPoint(hit.overlapEnd)}]`
412
+ )), ...arrowheadCollisions.map((hit) => (
413
+ `[composition/arrowhead-collision] ${qualityProfile} ${relationshipName(hit.left.relation)} and ${relationshipName(hit.right.relation)} have incoming arrowheads ${hit.distance}px apart (minimum ${hit.minimum}px) — enlarge or reposition the destination, or choose separate toSide ports.`
414
+ ))],
415
+ );
416
+ addCheck(
417
+ 'container_border_runs',
418
+ !qualityGatesEnforced || containerBorderRuns.length === 0,
419
+ containerBorderRuns.map((hit) => (
420
+ `[composition/container-border-run] ${relationshipName(hit.relation)} follows ${frameName(hit.frame)} ${hit.side} border for ${Math.round(hit.overlapLength * 10) / 10}px on segment ${hit.segmentIndex} [${formatPoint(hit.overlapStart)}] -> [${formatPoint(hit.overlapEnd)}]`
421
+ )),
422
+ );
423
+ addCheck(
424
+ 'route_rhythm',
425
+ !rhythmIsError || routeRhythmIssues.length === 0,
426
+ routeRhythmIssues.map((hit) => (
427
+ `[${hit.code}] ${qualityProfile} ${relationshipName(hit.relation)} has a ${Math.round(hit.length * 10) / 10}px ${hit.position} segment ${hit.segmentIndex} [${formatPoint(hit.start)}] -> [${formatPoint(hit.end)}]`
428
+ )),
429
+ );
430
+
431
+ if (legendStart >= 0) {
432
+ const legendFragment = svg.slice(legendStart);
433
+ const legendBoxes = collectLegendBoxes(legendFragment);
434
+ const collisions = collectLegendCollisions(arrows, legendBoxes);
435
+ addCheck(
436
+ 'legend_clearance',
437
+ collisions.length === 0,
438
+ collisions.map((hit) => `${hit.arrow.kind} ${hit.arrow.index} crosses legend ${hit.box.label}`),
439
+ );
440
+ } else {
441
+ addCheck('legend_clearance', true, ['no legend marker found']);
442
+ }
443
+ }
444
+
445
+ if (pathDiagnostics.length) addCheck('svg_path_data', false, pathDiagnostics.map(item => item.message));
446
+
447
+ const ok = checks.every((check) => check.ok) && composition.status !== 'fail';
448
+ console.log(JSON.stringify({ ok, file: htmlPath, artifact, checks, composition,
449
+ ...(pathDiagnostics.length ? { diagnostics: pathDiagnostics } : {}),
450
+ }, null, 2));
451
+ // Let pending stdout writes drain: large receipts are asynchronous when piped.
452
+ process.exitCode = ok ? 0 : 1;
453
+
454
+ function collectArrows(fragment, useActualPoints = false) {
455
+ const arrows = [];
456
+ let index = 0;
457
+ let pathIndex = 0;
458
+ let previousTag = null;
459
+ let previousTagEnd = 0;
460
+
461
+ for (const tag of fragment.matchAll(SVG_TAG_TOKEN)) {
462
+ const name = tag[2]?.toLowerCase();
463
+ if (tag[1] || !['path', 'line'].includes(name)) continue;
464
+ if (name === 'path') pathIndex += 1;
465
+ const tagStart = tag.index;
466
+ const raw = tag[0];
467
+ const gap = previousTag ? fragment.slice(previousTagEnd, tagStart) : '';
468
+ const precedingUnderlay = previousTag
469
+ && /^\s*$/.test(gap)
470
+ && previousTag.name === 'path'
471
+ && previousTag.underlayAttrs;
472
+ previousTag = {
473
+ name: name,
474
+ underlayAttrs: name === 'path' && AUTOMATIC_CROSSOVER_UNDERLAY_TAG.test(raw)
475
+ ? parseAttrs(raw)
476
+ : null,
477
+ };
478
+ previousTagEnd = tagStart + raw.length;
479
+ if (!/\bclass="[^"]*\ba-(?:default|emphasis|security|dashed)\b/.test(raw)) continue;
480
+ if (!/\bmarker-end=/.test(raw)) continue;
481
+ const attrs = parseAttrs(raw);
482
+ const routeStrokeWidth = numberAttr(attrs, 'stroke-width');
483
+ const underlayStrokeWidth = precedingUnderlay ? numberAttr(precedingUnderlay, 'stroke-width') : NaN;
484
+ const verifiedCrossoverHalo = attrs['data-composition-crossover'] === 'halo'
485
+ && precedingUnderlay?.d === attrs.d
486
+ && precedingUnderlay?.fill === 'none'
487
+ && precedingUnderlay?.stroke === 'var(--mask)'
488
+ && precedingUnderlay?.['pointer-events'] === 'none'
489
+ && Number.isFinite(routeStrokeWidth)
490
+ && Number.isFinite(underlayStrokeWidth)
491
+ && underlayStrokeWidth >= routeStrokeWidth + 3;
492
+ const line = name === 'line';
493
+ const parsed = line ? null : inspectedPath(attrs, tag.index, pathIndex, 'relationship');
494
+ const segments = line ? lineSegments(attrs) : parsed.subpaths.flatMap(part => part.segments);
495
+ const borderSegments = line ? segments : parsed.subpaths.flatMap(part => part.straightSegments);
496
+ let routeSubpaths = line ? continuousRoutePoints(segments)
497
+ : parsed.subpaths.flatMap(part => continuousRoutePoints(part.straightSegments));
498
+ // Legacy single-path metadata remains supported. It cannot bridge actual moveto boundaries.
499
+ if (!useActualPoints && (line || parsed.subpaths.length === 1)) {
500
+ const authoredPoints = parseRoutePoints(attrs['data-composition-points']);
501
+ if (authoredPoints) routeSubpaths = [authoredPoints];
502
+ }
503
+ arrows.push({
504
+ kind: name,
505
+ index: index += 1,
506
+ raw,
507
+ // Trust route intent only for a semantic edge with one visible direct
508
+ // segment. A stale marker on bent/curved geometry cannot waive the gate.
509
+ authoredStraight: attrs['data-composition-route'] === 'straight'
510
+ && Boolean(attrs['data-edge-from'] && attrs['data-edge-to'])
511
+ && segments.length === 1 && borderSegments.length === 1
512
+ && (name === 'line' || /^\s*M\s+[-+\d.eE]+\s+[-+\d.eE]+\s+L\s+[-+\d.eE]+\s+[-+\d.eE]+\s*$/.test(attrs.d || '')),
513
+ crossoverHalo: verifiedCrossoverHalo,
514
+ independentPorts: verifiedCrossoverHalo && attrs['data-composition-independent'] === 'true',
515
+ // Compatibility with first-round exports lacking a root layout contract.
516
+ // Readable-v2's root contract supersedes this narrower automatic-pair rule.
517
+ // This marker never certifies a crossover halo or waives a quality rule.
518
+ automaticWorkflowRoute: attrs['data-composition-routing'] === 'workflow-v2-auto',
519
+ junction: attrs['data-composition-junction'] || null,
520
+ width: routeStrokeWidth,
521
+ variant: raw.match(/\ba-(default|emphasis|security|dashed)\b/)?.[1] || 'default',
522
+ role: attrs['data-edge-role'],
523
+ segments,
524
+ borderSegments,
525
+ routePoints: routeSubpaths.length === 1 ? routeSubpaths[0] : [],
526
+ routeSubpaths,
527
+ from: attrs['data-edge-from'] || attrs['data-composition-edge-from'],
528
+ to: attrs['data-edge-to'] || attrs['data-composition-edge-to'],
529
+ id: attrs['data-edge-id'] || attrs['data-composition-edge-id'],
530
+ key: attrs['data-edge-key'],
531
+ label: attrs['data-edge-label'],
532
+ offset: tag.index,
533
+ });
534
+ }
535
+
536
+ return arrows;
537
+ }
538
+
539
+ function continuousRoutePoints(segments) {
540
+ const parts = [];
541
+ for (const { start, end } of segments) {
542
+ const previous = parts.at(-1);
543
+ // 非共线 Q 不参与直线预算;其两端也不能被拼接成一条虚构直线。
544
+ if (previous && previous.at(-1)[0] === start[0] && previous.at(-1)[1] === start[1]) {
545
+ previous.push(end);
546
+ } else {
547
+ parts.push([start, end]);
548
+ }
549
+ }
550
+ // 保留没有直线的子路径位置,避免把前一片段误认作语义终点。
551
+ return parts.length ? parts : [[]];
552
+ }
553
+
554
+ function inspectedPath(attrs, offset, pathIndex, role) {
555
+ if (pathAnalyses.has(offset)) return pathAnalyses.get(offset);
556
+ const result = analyzeSvgPath(decodeNumericReferences(attrs.d || ''));
557
+ if (result.ok) {
558
+ pathAnalyses.set(offset, result);
559
+ return result;
560
+ }
561
+ const { code, ...evidence } = result.error;
562
+ const id = attrs.id || attrs['data-edge-id'] || attrs['data-composition-frame-id'];
563
+ pathDiagnostics.push({
564
+ code,
565
+ severity: 'error',
566
+ message: `${role} path ${pathIndex}${id ? ` (${id})` : ''}: ${evidence.reason} at d offset ${evidence.tokenOffset}.`,
567
+ subject: { role, pathIndex, ...(id ? { id } : {}) },
568
+ evidence,
569
+ supportedFixes: [code === 'artifact/svg-path-unsupported'
570
+ ? 'regenerate the inspected relationship or frame using M/L/H/V/Q/Z commands'
571
+ : 'correct the path data at the reported offset or regenerate it from the original diagram'],
572
+ });
573
+ const invalid = { ok: false, subpaths: [] };
574
+ pathAnalyses.set(offset, invalid);
575
+ return invalid;
576
+ }
577
+
578
+ // Keep relationship identity separate from its disconnected drawing pieces.
579
+ // Normalizing each piece also makes evidence segment indices stable across consumers.
580
+ function routeParts(entry) {
581
+ const subpaths = entry.relation.routeSubpaths || [entry.points];
582
+ let segmentOffset = 0;
583
+ return subpaths.flatMap((points, subpathIndex) => {
584
+ const normalized = normalizeRoutePoints(points);
585
+ const part = { ...entry, points: normalized, segmentOffset,
586
+ sourceEndpoint: subpathIndex === 0, targetEndpoint: subpathIndex === subpaths.length - 1 };
587
+ segmentOffset += Math.max(0, normalized.length - 1);
588
+ return normalized.length > 1 ? [part] : [];
589
+ });
590
+ }
591
+
592
+ function subpathBudgetMetrics(entries) {
593
+ if (entries.every(entry => entry.relation.routeSubpaths.length <= 1)) {
594
+ return routeBudgetMetrics({ routedRelations: entries });
595
+ }
596
+ const total = routeBudgetMetrics({ routedRelations: [] });
597
+ for (const entry of entries) {
598
+ const parts = routeParts(entry);
599
+ const metrics = routeBudgetMetrics({ routedRelations: parts });
600
+ metrics.maxBends = parts.reduce((sum, part) => sum + Math.max(0, part.points.length - 2), 0);
601
+ // A relationship may exceed a budget in several pieces; count its identity once.
602
+ metrics.routesOverSuggestedBends = Number(metrics.maxBends > 2);
603
+ metrics.routesOverSuggestedStretch = Number(metrics.routesOverSuggestedStretch > 0);
604
+ for (const [key, value] of Object.entries(metrics)) {
605
+ if (key.startsWith('max')) total[key] = value == null ? total[key] : Math.max(total[key] ?? value, value);
606
+ else if (key.startsWith('min')) total[key] = value == null ? total[key] : Math.min(total[key] ?? value, value);
607
+ else total[key] += value;
608
+ }
609
+ }
610
+ return total;
611
+ }
612
+
613
+ function subpathRhythmIssues(entries) {
614
+ return entries.flatMap(entry => routeParts(entry).flatMap(part => (
615
+ collectRouteRhythmIssues({ routedRelations: [part] }).map(issue => ({
616
+ ...issue, segmentIndex: issue.segmentIndex + part.segmentOffset,
617
+ }))
618
+ )));
619
+ }
620
+
621
+ function subpathCorridors({ routedRelations, ...options }) {
622
+ if (routedRelations.every(entry => entry.relation.routeSubpaths.length <= 1)) {
623
+ return collectAmbiguousCorridors({ routedRelations, ...options });
624
+ }
625
+ const hits = [];
626
+ const parts = routedRelations.map(routeParts);
627
+ for (let left = 0; left < parts.length; left += 1) {
628
+ for (let right = left + 1; right < parts.length; right += 1) {
629
+ let longest;
630
+ for (const a of parts[left]) for (const b of parts[right]) {
631
+ const hit = collectAmbiguousCorridors({ routedRelations: [a, b], ...options })[0];
632
+ if (hit && (!longest || hit.overlapLength > longest.overlapLength + 0.0001)) {
633
+ longest = { ...hit, leftSegment: hit.leftSegment + a.segmentOffset,
634
+ rightSegment: hit.rightSegment + b.segmentOffset };
635
+ }
636
+ }
637
+ if (longest) hits.push(longest);
638
+ }
639
+ }
640
+ return hits;
641
+ }
642
+
643
+ function subpathLabelClearance({ routedRelations, labels, threshold }) {
644
+ if (routedRelations.every(entry => entry.relation.routeSubpaths.length <= 1)) {
645
+ return collectLabelRouteClearance({ routedRelations, labels, threshold });
646
+ }
647
+ // The shared helper deduplicates by relationship identity. Measure each piece
648
+ // independently, then preserve one nearest hit per label/relationship pair.
649
+ const hits = [];
650
+ const seen = new Set();
651
+ for (const entry of routedRelations) {
652
+ const relation = entry.relation;
653
+ const identity = relation.key !== undefined ? `key:${relation.key}`
654
+ : relation.id ? `id:${relation.from || ''}\0${relation.to || ''}\0${relation.id}` : `index:${entry.relationIndex}`;
655
+ if (seen.has(identity) || !routeParts(entry).length) continue;
656
+ seen.add(identity);
657
+ const nearest = new Map();
658
+ for (const part of routeParts(entry)) {
659
+ for (const hit of collectLabelRouteClearance({ routedRelations: [part], labels, threshold })) {
660
+ const current = nearest.get(hit.labelRelationIndex);
661
+ if (!current || hit.clearance < current.clearance) nearest.set(hit.labelRelationIndex, {
662
+ ...hit, segmentIndex: hit.segmentIndex + part.segmentOffset,
663
+ });
664
+ }
665
+ }
666
+ hits.push(...nearest.values());
667
+ }
668
+ return hits;
669
+ }
670
+
671
+ function collectRelationshipLabelMasks(fragment, arrows) {
672
+ const labels = [];
673
+ for (const match of fragment.matchAll(/<g\b[^>]*\bdata-edge-(?:key|id|from)="[^"]*"[^>]*>[\s\S]*?<\/g>/gi)) {
674
+ const group = match[0];
675
+ const groupAttrs = parseAttrs(group.match(/<g\b[^>]*>/i)?.[0] || '');
676
+ const rectTag = [...group.matchAll(/<rect\b[^>]*>/gi)]
677
+ .map((item) => item[0])
678
+ .find((tag) => /\bclass="[^"]*\bc-mask\b/.test(tag));
679
+ if (!rectTag) continue;
680
+ const attrs = parseAttrs(rectTag);
681
+ const rect = {
682
+ x: numberAttr(attrs, 'x'),
683
+ y: numberAttr(attrs, 'y'),
684
+ width: numberAttr(attrs, 'width'),
685
+ height: numberAttr(attrs, 'height'),
686
+ };
687
+ if (![rect.x, rect.y, rect.width, rect.height].every(Number.isFinite)) continue;
688
+ const groupStart = match.index;
689
+ const groupEnd = groupStart + group.length;
690
+ const containedOwner = arrows.find((arrow) => (
691
+ arrow.offset > groupStart
692
+ && arrow.offset < groupEnd
693
+ && arrow.from === groupAttrs['data-edge-from']
694
+ && arrow.to === groupAttrs['data-edge-to']
695
+ && (!groupAttrs['data-edge-id'] || !arrow.id || arrow.id === groupAttrs['data-edge-id'])
696
+ ));
697
+ const owner = arrows.find((arrow) => (
698
+ groupAttrs['data-edge-key'] !== undefined && arrow.key === groupAttrs['data-edge-key']
699
+ )) || containedOwner || arrows.find((arrow) => (
700
+ arrow.id === groupAttrs['data-edge-id']
701
+ && arrow.from === groupAttrs['data-edge-from']
702
+ && arrow.to === groupAttrs['data-edge-to']
703
+ ));
704
+ if (!owner) continue;
705
+ if (owner.key === undefined && groupAttrs['data-edge-key'] !== undefined) owner.key = groupAttrs['data-edge-key'];
706
+ if (!owner.id && groupAttrs['data-edge-id']) owner.id = groupAttrs['data-edge-id'];
707
+ if (!owner.label && groupAttrs['data-edge-label']) owner.label = groupAttrs['data-edge-label'];
708
+ labels.push({
709
+ relation: owner,
710
+ relationIndex: owner.index,
711
+ label: groupAttrs['data-edge-label'] || '',
712
+ rect,
713
+ });
714
+ }
715
+ return labels;
716
+ }
717
+
718
+ function roundedRect(rect) {
719
+ return Object.fromEntries(Object.entries(rect).map(([key, value]) => [key, Math.round(value * 10) / 10]));
720
+ }
721
+
722
+ function roundedRouteMetrics(metrics) {
723
+ return {
724
+ ...metrics,
725
+ maxStretch: metrics.maxStretch == null ? null : Math.round(metrics.maxStretch * 1000) / 1000,
726
+ minSegmentPx: metrics.minSegmentPx == null ? null : Math.round(metrics.minSegmentPx * 10) / 10,
727
+ minInteriorSegmentPx: metrics.minInteriorSegmentPx == null ? null : Math.round(metrics.minInteriorSegmentPx * 10) / 10,
728
+ };
729
+ }
730
+
731
+ function parseRoutePoints(value) {
732
+ if (!value) return null;
733
+ const points = value.split(';').map((pair) => pair.split(',').map(Number));
734
+ return points.length >= 2 && points.every(isPoint) ? points : null;
735
+ }
736
+
737
+ function collectRelationshipCrossings(arrows, includeSharedEndpoints = false) {
738
+ const relationships = arrows.filter((arrow) => arrow.from && arrow.to && arrow.segments.length).map(arrow => ({
739
+ ...arrow,
740
+ // Readable-v2 routePoints come from the visible path, never its metadata.
741
+ // A straight-through via is not a visual endpoint; preserve real bends.
742
+ segments: includeSharedEndpoints ? arrow.routeSubpaths.flatMap(forwardCollinearAnalysisSegments) : arrow.segments,
743
+ }));
744
+ const crossings = [];
745
+ for (let leftIndex = 0; leftIndex < relationships.length; leftIndex += 1) {
746
+ const left = relationships[leftIndex];
747
+ for (let rightIndex = leftIndex + 1; rightIndex < relationships.length; rightIndex += 1) {
748
+ const right = relationships[rightIndex];
749
+ // A shared semantic endpoint does not make an interior X a junction
750
+ // when both paths opt into automatic workflow or independent-port checks.
751
+ if ([left.from, left.to].some((id) => id === right.from || id === right.to)
752
+ && !includeSharedEndpoints
753
+ && !(left.independentPorts && right.independentPorts)
754
+ && !(left.automaticWorkflowRoute && right.automaticWorkflowRoute)) continue;
755
+ let point = null;
756
+ for (const leftSegment of left.segments) {
757
+ for (const rightSegment of right.segments) {
758
+ point = properSegmentIntersection(leftSegment.start, leftSegment.end, rightSegment.start, rightSegment.end);
759
+ if (point) break;
760
+ }
761
+ if (point) break;
762
+ }
763
+ if (point) crossings.push({ left, right, point });
764
+ }
765
+ }
766
+ return crossings;
767
+ }
768
+
769
+ function relationshipName(arrow) {
770
+ return arrow.id
771
+ ? `relationship id "${arrow.id}" ("${arrow.from}" -> "${arrow.to}")`
772
+ : `relationship "${arrow.from}" -> "${arrow.to}"`;
773
+ }
774
+
775
+ // Review evidence only. Mask rectangles are the visible node bounds emitted by
776
+ // our renderers. Skip transformed ancestry rather than mixing coordinate spaces.
777
+ function collectUntransformedNodeRects(fragment) {
778
+ const groups = [];
779
+ const nodes = new Map();
780
+ for (const token of fragment.matchAll(SVG_TAG_TOKEN)) {
781
+ if (!token[2]) continue;
782
+ const name = token[2].toLowerCase();
783
+ if (name === 'g') {
784
+ if (token[1]) groups.pop();
785
+ else if (!/\/\s*>$/.test(token[0])) groups.push(parseAttrs(token[0]));
786
+ continue;
787
+ }
788
+ if (name !== 'rect' || token[1] || groups.some((group) => group.transform)) continue;
789
+ const attrs = parseAttrs(token[0]);
790
+ const owner = [...groups].reverse().find((group) => group['data-node-id']);
791
+ if (!owner || attrs.transform || !String(attrs.class || '').split(/\s+/).includes('c-mask')) continue;
792
+ const box = ['x', 'y', 'width', 'height'].map((key) => numberAttr(attrs, key));
793
+ if (!box.every(Number.isFinite) || box[2] <= 0 || box[3] <= 0) continue;
794
+ const id = owner['data-node-id'];
795
+ // Ambiguous ownership is not reliable evidence for moving a node.
796
+ nodes.set(id, nodes.has(id) ? null : { id, label: owner['data-node-label'] || id, box });
797
+ }
798
+ return [...nodes.values()].filter(Boolean);
799
+ }
800
+
801
+ function directCorridorBlockers(relation, nodes) {
802
+ const from = nodes.find((node) => node.id === relation.from);
803
+ const to = nodes.find((node) => node.id === relation.to);
804
+ if (!from || !to || from === to) return [];
805
+ const center = ({ box: [x, y, w, h] }) => [x + w / 2, y + h / 2];
806
+ const a = center(from);
807
+ const b = center(to);
808
+ const horizontal = Math.abs(a[1] - b[1]) < 0.01;
809
+ const vertical = Math.abs(a[0] - b[0]) < 0.01;
810
+ if (horizontal === vertical) return [];
811
+ const axis = horizontal ? 0 : 1;
812
+ const cross = 1 - axis;
813
+ const [first, last] = a[axis] < b[axis] ? [from, to] : [to, from];
814
+ const low = first.box[axis] + first.box[axis + 2];
815
+ const high = last.box[axis];
816
+ if (high <= low) return [];
817
+ return nodes.filter((node) => node !== from && node !== to
818
+ && node.box[axis] < high && node.box[axis] + node.box[axis + 2] > low
819
+ && node.box[cross] < a[cross] && node.box[cross] + node.box[cross + 2] > a[cross]);
820
+ }
821
+
822
+ // Automatic ports need a 16px corner gutter and 14px between neighbours, so a
823
+ // side facing more counterparts than that fits pushes routes onto other sides.
824
+ function crowdedNodeSides(arrows, nodes) {
825
+ const byId = new Map(nodes.map((node) => [node.id, node]));
826
+ const demand = new Map();
827
+ for (const arrow of arrows) {
828
+ const from = byId.get(arrow.from);
829
+ const to = byId.get(arrow.to);
830
+ if (!from || !to || from === to) continue;
831
+ for (const [node, other] of [[from, to], [to, from]]) {
832
+ const [x, y, w, h] = node.box;
833
+ const dx = other.box[0] + other.box[2] / 2 - (x + w / 2);
834
+ const dy = other.box[1] + other.box[3] / 2 - (y + h / 2);
835
+ const side = dx !== 0 && Math.abs(dx) >= Math.abs(dy) ? (dx < 0 ? 'left' : 'right') : (dy > 0 ? 'bottom' : 'top');
836
+ const key = `${node.id}\u0000${side}`;
837
+ const entry = demand.get(key) || { node, side, relationships: 0 };
838
+ entry.relationships += 1;
839
+ demand.set(key, entry);
840
+ }
841
+ }
842
+ return [...demand.values()].flatMap(({ node, side, relationships }) => {
843
+ const sidePx = side === 'left' || side === 'right' ? node.box[3] : node.box[2];
844
+ const neededPx = 32 + 14 * (relationships - 1);
845
+ return relationships > 1 && sidePx < neededPx
846
+ ? [{ node: node.id, label: node.label, side, relationships, sidePx, neededPx }] : [];
847
+ });
848
+ }
849
+
850
+ function relationshipRecord(arrow) {
851
+ const stableIndex = Number(arrow.key);
852
+ return {
853
+ id: arrow.id,
854
+ from: arrow.from,
855
+ to: arrow.to,
856
+ label: arrow.label || '',
857
+ collectionIndex: Number.isInteger(stableIndex) && stableIndex >= 0 ? stableIndex : arrow.index - 1,
858
+ artifactIndex: arrow.index,
859
+ };
860
+ }
861
+
862
+ function collectCompositionFrames(fragment) {
863
+ const frames = [];
864
+ let pathIndex = 0;
865
+ for (const match of fragment.matchAll(SVG_TAG_TOKEN)) {
866
+ const name = match[2]?.toLowerCase();
867
+ if (match[1] || !['rect', 'path', 'line'].includes(name)) continue;
868
+ if (name === 'path') pathIndex += 1;
869
+ const attrs = parseAttrs(match[0]);
870
+ const kind = attrs['data-composition-frame-kind'];
871
+ if (!kind) continue;
872
+ const identity = attrs['data-composition-frame-id'] || frames.length;
873
+ if (name === 'rect') {
874
+ const frame = {
875
+ kind,
876
+ id: identity,
877
+ x: numberAttr(attrs, 'x'),
878
+ y: numberAttr(attrs, 'y'),
879
+ width: numberAttr(attrs, 'width'),
880
+ height: numberAttr(attrs, 'height'),
881
+ radius: numberAttr(attrs, 'rx') || 0,
882
+ };
883
+ if ([frame.x, frame.y, frame.width, frame.height].every(Number.isFinite)) frames.push(frame);
884
+ continue;
885
+ }
886
+ const segments = name === 'line'
887
+ ? lineSegments(attrs)
888
+ : inspectedPath(attrs, match.index, pathIndex, 'structural-frame').subpaths.flatMap(part => part.segments);
889
+ for (const [segmentIndex, segment] of segments.entries()) {
890
+ frames.push({
891
+ kind,
892
+ id: segments.length > 1 ? `${identity}:${segmentIndex}` : identity,
893
+ shape: 'line',
894
+ start: segment.start,
895
+ end: segment.end,
896
+ });
897
+ }
898
+ }
899
+ return frames;
900
+ }
901
+
902
+ // Evidence for author review only. Automatic Architecture is identifiable by
903
+ // its intrinsic Reader contract; authored canvases must retain their geometry.
904
+ function collectArchitectureLeadingSpace({ svgAttrs, fragment, nodeRects, frames, arrows, labels }) {
905
+ const evidence = { measured: false, reviewSuggested: false };
906
+ if (svgAttrs['data-reader-fit'] !== 'intrinsic-height'
907
+ || svgAttrs['data-reader-primary-text'] !== '14'
908
+ || svgAttrs.transform
909
+ || [...fragment.matchAll(/<g\b[^>]*\btransform\s*=[^>]*>/gi)]
910
+ .some((match) => !/\bdata-semantic-sigil=/.test(match[0]))
911
+ || /<(?:path|line|rect|text)\b[^>]*\btransform\s*=/i.test(fragment)) return evidence;
912
+ const [originX, originY, width, height] = viewBoxRect(svgAttrs);
913
+ if (![originX, originY, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return evidence;
914
+ const nodeCount = [...fragment.matchAll(/<g\b[^>]*\bdata-node-id=/gi)].length;
915
+ if (!nodeRects.length || nodeRects.length !== nodeCount) return evidence;
916
+ const semanticArrows = arrows.filter((arrow) => arrow.from && arrow.to);
917
+ if (semanticArrows.some((arrow) => !arrow.routeSubpaths.some(points => points.length))) return evidence;
918
+
919
+ const occupied = nodeRects.map((node) => node.box[1]);
920
+ for (const frame of frames) {
921
+ const top = frame.shape === 'line'
922
+ ? Math.min(frame.start[1], frame.end[1]) : frame.y;
923
+ if (!Number.isFinite(top)) return evidence;
924
+ occupied.push(top);
925
+ }
926
+ // Boundary titles can protrude above their structural frame.
927
+ for (const match of fragment.matchAll(/<g\b[^>]*\bdata-graph-role="structural-frame-label"[^>]*>[\s\S]*?<rect\b[^>]*\bdata-graph-role="structural-frame-label-mask"[^>]*>/gi)) {
928
+ const rect = parseAttrs(match[0].match(/<rect\b[^>]*>/i)?.[0] || '');
929
+ const y = numberAttr(rect, 'y');
930
+ if (!Number.isFinite(y)) return evidence;
931
+ occupied.push(y);
932
+ }
933
+ for (const arrow of semanticArrows) {
934
+ for (const points of arrow.routeSubpaths) for (const point of points) occupied.push(point[1]);
935
+ }
936
+ for (const label of labels) occupied.push(label.rect.y);
937
+ if (!occupied.every(Number.isFinite)) return evidence;
938
+ const occupiedTop = Math.min(...occupied);
939
+ const gap = Math.max(0, occupiedTop - originY);
940
+ const heights = nodeRects.map((node) => node.box[3]).sort((a, b) => a - b);
941
+ const typicalNodeHeight = heights[Math.floor(heights.length / 2)];
942
+ const ratio = gap / height;
943
+ return {
944
+ measured: true,
945
+ emptyTopPx: Math.round(gap * 10) / 10,
946
+ emptyTopRatio: Math.round(ratio * 1000) / 1000,
947
+ occupiedTop: Math.round(occupiedTop * 10) / 10,
948
+ viewBoxTop: originY,
949
+ canvasHeight: height,
950
+ typicalNodeHeight,
951
+ reviewSuggested: gap > 2 * typicalNodeHeight && ratio > 0.2,
952
+ };
953
+ }
954
+
955
+ // Advisory only: fixed columns are a compatibility contract. Measure semantic
956
+ // content, including long labels/notes, rather than treating every wide canvas
957
+ // as wasted space. Auto-sized segment frames do not add a participant column.
958
+ function collectSequenceColumnSpace({ svgAttrs, fragment, nodeRects, arrows }) {
959
+ const columnFit = svgAttrs['data-sequence-column-fit'];
960
+ if (!['fixed', 'spread'].includes(columnFit)) return null;
961
+ const evidence = { measured: false, reviewSuggested: false, columnFit };
962
+ if (svgAttrs.transform) return evidence;
963
+ // Brand badges stay inside their participant box. Ignore only that subtree's
964
+ // transforms, including the preset path or nested fallback icon's scale.
965
+ const brandGroups = [];
966
+ for (const token of fragment.matchAll(SVG_TAG_TOKEN)) {
967
+ if (!token[2]) continue;
968
+ const name = token[2].toLowerCase();
969
+ if (token[1]) {
970
+ if (name === 'g') brandGroups.pop();
971
+ continue;
972
+ }
973
+ const attrs = parseAttrs(token[0]);
974
+ const inBrand = brandGroups.at(-1) === true || (name === 'g'
975
+ && Boolean(attrs['data-brand-mark'])
976
+ && String(attrs.class || '').split(/\s+/).includes('brand-mark'));
977
+ if (!inBrand && attrs.transform && ['g', 'path', 'line', 'rect', 'text'].includes(name)
978
+ && !(name === 'g' && attrs['data-semantic-sigil'])) return evidence;
979
+ if (name === 'g' && !/\/\s*>$/.test(token[0])) brandGroups.push(inBrand);
980
+ }
981
+ const [originX, , width, height] = viewBoxRect(svgAttrs);
982
+ if (![originX, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return evidence;
983
+ const nodeCount = [...fragment.matchAll(/<g\b[^>]*\bdata-node-id=/gi)].length;
984
+ if (!nodeRects.length || nodeCount !== nodeRects.length) return evidence;
985
+ const semanticArrows = arrows.filter((arrow) => arrow.from && arrow.to);
986
+ if (semanticArrows.some((arrow) => !arrow.routeSubpaths.some(points => points.length))) return evidence;
987
+ const rightEdges = nodeRects.map((node) => node.box[0] + node.box[2]);
988
+ for (const arrow of semanticArrows) {
989
+ for (const points of arrow.routeSubpaths) for (const point of points) rightEdges.push(point[0]);
990
+ }
991
+ // Label plates, activations and segment titles also reserve horizontal room.
992
+ for (const match of fragment.matchAll(/<rect\b[^>]*>/gi)) {
993
+ const attrs = parseAttrs(match[0]);
994
+ if (!String(attrs.class || '').split(/\s+/).includes('c-mask')) continue;
995
+ rightEdges.push(numberAttr(attrs, 'x') + numberAttr(attrs, 'width'));
996
+ }
997
+ for (const match of fragment.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text>/gi)) {
998
+ const attrs = parseAttrs(match[1]);
999
+ // A wrapped note places each line in a <tspan> with its own x. Any other
1000
+ // line offset is not measured here.
1001
+ const lines = /<tspan\b/i.test(match[2])
1002
+ ? [...match[2].matchAll(/<tspan\b([^>]*)>([\s\S]*?)<\/tspan>/gi)]
1003
+ .map((line) => ({ attrs: { ...attrs, ...parseAttrs(line[1]) }, text: stripTags(line[2]).trim() }))
1004
+ : [{ attrs, text: stripTags(match[2]).trim() }];
1005
+ if (!lines.length || lines.some((line) => line.attrs.dx !== undefined)) return evidence;
1006
+ for (const line of lines) {
1007
+ const box = textBox(line.attrs, line.text);
1008
+ if (!box) return evidence;
1009
+ rightEdges.push(box.x2);
1010
+ }
1011
+ }
1012
+ if (!rightEdges.every(Number.isFinite)) return evidence;
1013
+ const occupiedRight = Math.max(...rightEdges);
1014
+ const gap = Math.max(0, originX + width - occupiedRight);
1015
+ const ratio = gap / width;
1016
+ const widths = nodeRects.map((node) => node.box[2]).sort((a, b) => a - b);
1017
+ const typicalParticipantWidth = widths[Math.floor(widths.length / 2)];
1018
+ return {
1019
+ measured: true,
1020
+ columnFit,
1021
+ participantCount: nodeCount,
1022
+ occupiedRight: Math.round(occupiedRight * 10) / 10,
1023
+ viewBoxLeft: originX,
1024
+ canvasWidth: width,
1025
+ emptyRightPx: Math.round(gap * 10) / 10,
1026
+ emptyRightRatio: Math.round(ratio * 1000) / 1000,
1027
+ typicalParticipantWidth,
1028
+ // Avoid stretching a small conversation merely to fill its canvas. These
1029
+ // conservative review thresholds never contribute errors or warnings.
1030
+ reviewSuggested: columnFit === 'fixed' && nodeCount >= 4
1031
+ && gap > 2 * typicalParticipantWidth && ratio > 0.25,
1032
+ };
1033
+ }
1034
+
1035
+ // Sequence notes are direct text children of a semantic message group; message
1036
+ // labels use a nested group. Inspect the emitted lines without changing layout.
1037
+ // This is advisory: phase ownership still belongs to the author.
1038
+ function collectSequenceNoteBorders({ svgAttrs, fragment, frames, arrows }) {
1039
+ if (!svgAttrs['data-sequence-column-fit'] || svgAttrs.transform) return [];
1040
+ const segments = frames.filter(frame => frame.kind === 'segment' && !frame.shape);
1041
+ if (!segments.length) return [];
1042
+ const issues = [], stack = [];
1043
+ for (const tag of fragment.matchAll(SVG_TAG_TOKEN)) {
1044
+ const name = tag[2]?.toLowerCase();
1045
+ if (name === 'g') {
1046
+ if (tag[1]) stack.pop();
1047
+ else if (!tag[0].endsWith('/>')) stack.push(parseAttrs(tag[0]));
1048
+ continue;
1049
+ }
1050
+ if (name === 'rect' && !tag[1]) {
1051
+ const attrs = parseAttrs(tag[0]);
1052
+ if (attrs['data-composition-frame-kind'] === 'segment' && (attrs.transform || stack.some(group => group.transform))) return [];
1053
+ }
1054
+ if (name !== 'text' || tag[1] || stack.some(attrs => attrs.transform)) continue;
1055
+ const owner = stack.at(-1), attrs = parseAttrs(tag[0]);
1056
+ if (!owner?.['data-edge-key'] || !['context', 'fine'].includes(attrs['data-detail']) || attrs.transform || attrs.rotate || attrs.dx || attrs.dy) continue;
1057
+ const index = Number(owner['data-edge-key']);
1058
+ if (!Number.isSafeInteger(index) || index < 0) continue;
1059
+ const arrow = arrows[index], arrowY = arrow?.routePoints?.[0]?.[1];
1060
+ if (!Number.isFinite(arrowY)) continue;
1061
+ const end = fragment.indexOf('</text>', tag.index + tag[0].length);
1062
+ if (end < 0) continue;
1063
+ const content = fragment.slice(tag.index + tag[0].length, end);
1064
+ let baseline = numberAttr(attrs, 'y');
1065
+ const boxes = [];
1066
+ const spans = [...content.matchAll(/<tspan\b([^>]*)>([\s\S]*?)<\/tspan>/gi)];
1067
+ let supported = true;
1068
+ for (const span of spans.length ? spans : [[null, '', content]]) {
1069
+ const line = parseAttrs(span[1]);
1070
+ if (line.y !== undefined || line.dx !== undefined || line.rotate !== undefined || line.transform !== undefined) { supported = false; break; }
1071
+ baseline += Number(line.dy || 0);
1072
+ const box = textBox({ ...attrs, ...line, y: String(baseline) }, stripTags(span[2]));
1073
+ if (!box || ![box.x1, box.x2, box.y1, box.y2].every(Number.isFinite)) { supported = false; break; }
1074
+ boxes.push(box);
1075
+ }
1076
+ if (!supported || !boxes.length) continue;
1077
+ const note = { x: Math.min(...boxes.map(b => b.x1)), y: Math.min(...boxes.map(b => b.y1)) };
1078
+ note.width = Math.max(...boxes.map(b => b.x2)) - note.x;
1079
+ note.height = Math.max(...boxes.map(b => b.y2)) - note.y;
1080
+ const bottom = note.y + note.height;
1081
+ for (const frame of segments) {
1082
+ const border = frame.y + frame.height;
1083
+ if (!(arrowY >= frame.y && arrowY < border && note.y < border && bottom > border + 0.5)) continue;
1084
+ const requiredTo = Math.ceil(bottom + 4);
1085
+ // A single-field fix may not swallow another message or stage, or push
1086
+ // the frame beyond an authored canvas. Otherwise ask for a joint review.
1087
+ const safe = /^\d+$/.test(String(frame.id)) && requiredTo <= viewBoxRect(svgAttrs)[1] + viewBoxRect(svgAttrs)[3] - 45
1088
+ && !arrows.some(other => other.routePoints.some(point => point[1] > border - 4 && point[1] < requiredTo + 4))
1089
+ && !segments.some(other => other !== frame && other.y < requiredTo && other.y + other.height > border);
1090
+ const segmentPath = `/segments/${frame.id}/to`;
1091
+ issues.push({
1092
+ code: 'sequence/note-segment-border', reviewSuggested: true,
1093
+ message: `Note on message "${owner['data-edge-label'] || owner['data-edge-id'] || index}" ends at y=${Math.ceil(bottom)}, crossing its segment bottom y=${border}. ${safe ? `Set ${segmentPath} to ${requiredTo} to keep the full note inside.` : 'Review the note and neighbouring phase boundaries together; keep the full note and message order.'}`,
1094
+ subject: { diagramType: 'sequence', path: `/messages/${index}/note` },
1095
+ evidence: { note, lines: boxes.length, messageY: arrowY, segment: { path: segmentPath, from: frame.y, to: border }, requiredTo },
1096
+ supportedFixes: safe ? [`set ${segmentPath} to ${requiredTo}`] : [],
1097
+ });
1098
+ }
1099
+ }
1100
+ return issues;
1101
+ }
1102
+
1103
+ function frameName(frame) {
1104
+ return `${frame.kind || 'frame'} "${frame.id}"`;
1105
+ }
1106
+
1107
+ function frameRecord(frame) {
1108
+ return { kind: frame.kind, id: frame.id };
1109
+ }
1110
+
1111
+ function formatPoint(point) {
1112
+ return point.map((value) => Math.round(value * 10) / 10).join(', ');
1113
+ }
1114
+
1115
+ function lineSegments(attrs) {
1116
+ const start = [numberAttr(attrs, 'x1'), numberAttr(attrs, 'y1')];
1117
+ const end = [numberAttr(attrs, 'x2'), numberAttr(attrs, 'y2')];
1118
+ if (!isPoint(start) || !isPoint(end)) return [];
1119
+ return [{ start, end }];
1120
+ }
1121
+
1122
+ function diagonalStraightSegments(arrow) {
1123
+ if (arrow.authoredStraight) return [];
1124
+ return arrow.borderSegments.flatMap(({ start, end }, segmentIndex) => (
1125
+ Math.abs(start[0] - end[0]) > 0.01 && Math.abs(start[1] - end[1]) > 0.01
1126
+ ? [{ segmentIndex, start, end }]
1127
+ : []
1128
+ ));
1129
+ }
1130
+
1131
+ function collectLegendBoxes(fragment) {
1132
+ const boxes = [];
1133
+
1134
+ for (const match of fragment.matchAll(/<rect\b[^>]*>/gi)) {
1135
+ const attrs = parseAttrs(match[0]);
1136
+ const x = numberAttr(attrs, 'x');
1137
+ const y = numberAttr(attrs, 'y');
1138
+ const width = numberAttr(attrs, 'width');
1139
+ const height = numberAttr(attrs, 'height');
1140
+ if ([x, y, width, height].every(Number.isFinite)) {
1141
+ boxes.push({ x1: x, y1: y, x2: x + width, y2: y + height, label: `rect@${x},${y}` });
1142
+ }
1143
+ }
1144
+
1145
+ for (const match of fragment.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text>/gi)) {
1146
+ const attrs = parseAttrs(match[1]);
1147
+ const box = textBox(attrs, stripTags(match[2]).trim(), legendTextWidth);
1148
+ if (box) boxes.push(box);
1149
+ }
1150
+
1151
+ return boxes;
1152
+ }
1153
+
1154
+ function collectLegendCollisions(arrows, boxes) {
1155
+ const collisions = [];
1156
+ for (const arrow of arrows) {
1157
+ for (const segment of arrow.segments) {
1158
+ for (const box of boxes) {
1159
+ if (segmentIntersectsBox(segment, padBox(box, 2))) {
1160
+ collisions.push({ arrow, box });
1161
+ }
1162
+ }
1163
+ }
1164
+ }
1165
+ return collisions;
1166
+ }
1167
+
1168
+ // The footprint a legend entry reserves is the renderer's own: textUnits() at
1169
+ // TEXT_ADVANCE_EM (renderers/shared/legend.mjs). The default estimate below stays
1170
+ // as it is for the readability projection, which is deliberately conservative.
1171
+ function legendTextWidth(text, fontSize) {
1172
+ const TEXT_ADVANCE_EM = 0.62;
1173
+ return Math.max(fontSize, textUnits(text) * fontSize * TEXT_ADVANCE_EM);
1174
+ }
1175
+
1176
+ function textBox(attrs, text, measure = estimatedTextWidth) {
1177
+ const x = numberAttr(attrs, 'x');
1178
+ const y = numberAttr(attrs, 'y');
1179
+ const fontSize = Number.parseFloat(attrs['font-size'] || '10');
1180
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(fontSize)) return null;
1181
+ const width = measure(text, fontSize);
1182
+ const anchor = attrs['text-anchor'] || 'start';
1183
+ let x1 = x;
1184
+ if (anchor === 'middle') x1 = x - width / 2;
1185
+ if (anchor === 'end') x1 = x - width;
1186
+ return {
1187
+ x1,
1188
+ y1: y - fontSize,
1189
+ x2: x1 + width,
1190
+ y2: y + fontSize * 0.25,
1191
+ label: text || `text@${x},${y}`,
1192
+ };
1193
+ }
1194
+
1195
+ // A foreign artifact may author a legal non-zero viewBox origin, so containment
1196
+ // needs all four numbers; sizing checks read the trailing pair.
1197
+ function viewBoxRect(svgAttrs) {
1198
+ const viewBox = String(svgAttrs.viewBox || '').trim().split(/[\s,]+/).map(Number);
1199
+ return viewBox.length === 4 ? viewBox : [Number.NaN, Number.NaN, Number.NaN, Number.NaN];
1200
+ }
1201
+
1202
+ function viewBoxSize(svgAttrs) {
1203
+ return viewBoxRect(svgAttrs).slice(2);
1204
+ }
1205
+
1206
+ function hasAttribute(attrs, name) {
1207
+ return new RegExp('(?:^|\\s)' + name + '(?:\\s*=|\\s|$)', 'i').test(attrs);
1208
+ }
1209
+
1210
+ function readerContractFromHtml(source) {
1211
+ const markers = [...source.matchAll(/<meta\b[^>]*>/gi)]
1212
+ .map((match) => parseAttrs(match[0]))
1213
+ .filter((attrs) => attrs.name === 'archify-reader-contract');
1214
+ return markers.length === 1 && markers[0].content === DECLARED_WIDE_READER_CONTRACT
1215
+ ? DECLARED_WIDE_READER_CONTRACT
1216
+ : null;
1217
+ }
1218
+
1219
+ function collectDesktopReadability(svgAttrs, fragment, contract) {
1220
+ const [viewBoxWidth, viewBoxHeight] = viewBoxSize(svgAttrs);
1221
+ const requestedMinimumTextPx = Number.parseFloat(svgAttrs['data-reader-min-text'] || '');
1222
+ const entries = [];
1223
+ let invalidSemanticText = false;
1224
+ const groups = [];
1225
+ // Keep the complete ancestry, rather than a node-only stack: an edge can
1226
+ // share its context group with its label or nest that group (Sequence).
1227
+ for (const match of fragment.matchAll(/<!--[\s\S]*?(?:-->|$)|<!\[CDATA\[[\s\S]*?(?:\]\]>|$)|<text\b([^>]*)>([\s\S]*?)<\/text>|<g\b[^>]*>|<\/g\s*>/gi)) {
1228
+ if (match[0].startsWith('<!')) continue;
1229
+ if (match[1] === undefined) {
1230
+ if (/^<\/g/i.test(match[0])) groups.pop();
1231
+ else if (!/\/\s*>$/.test(match[0])) groups.push(parseAttrs(match[0]));
1232
+ continue;
1233
+ }
1234
+ const attrs = parseAttrs(match[1]);
1235
+ if (attrs['data-detail'] === 'fine' || groups.some((group) => group['data-detail'] === 'fine')) continue;
1236
+ const primary = hasAttribute(match[1], 'data-node-label');
1237
+ const boundary = hasAttribute(match[1], 'data-boundary-label');
1238
+ const context = attrs['data-detail'] === 'context'
1239
+ || groups.some((group) => group['data-detail'] === 'context');
1240
+ const nodeOwner = [...groups].reverse().find((group) => group['data-node-id']);
1241
+ const edgeOwner = [...groups].reverse().find((group) => group['data-edge-from'] && group['data-edge-to']);
1242
+ const owner = primary
1243
+ ? { kind: 'node', id: nodeOwner?.['data-node-id'] || null }
1244
+ : boundary ? { kind: 'boundary', id: null }
1245
+ : context && edgeOwner ? {
1246
+ kind: 'edge', id: edgeOwner['data-edge-id'] || null,
1247
+ from: edgeOwner['data-edge-from'], to: edgeOwner['data-edge-to'],
1248
+ }
1249
+ : context && nodeOwner ? { kind: 'node', id: nodeOwner['data-node-id'] }
1250
+ : null;
1251
+ // A context text with neither semantic owner is legend/fine/loose copy.
1252
+ if (!owner) continue;
1253
+ const fontSize = Number.parseFloat(attrs['font-size'] || '');
1254
+ if (!Number.isFinite(fontSize)) {
1255
+ invalidSemanticText = true;
1256
+ continue;
1257
+ }
1258
+ if (fontSize <= 0) invalidSemanticText = true;
1259
+ entries.push({
1260
+ ...(owner.kind === 'node' && owner.id ? { nodeId: owner.id } : {}),
1261
+ owner,
1262
+ text: stripTags(match[2]).trim(),
1263
+ detail: primary ? 'primary' : boundary ? 'boundary' : owner.kind === 'edge' ? 'edge' : 'context',
1264
+ sourceFontPx: fontSize,
1265
+ });
1266
+ }
1267
+ const minimumSourceTextPx = entries.length ? Math.min(...entries.map((entry) => entry.sourceFontPx)) : Number.NaN;
1268
+ const eligible = contract === DECLARED_WIDE_READER_CONTRACT
1269
+ && ['intrinsic-height', 'width-first'].includes(svgAttrs['data-reader-fit'])
1270
+ && Number.isFinite(requestedMinimumTextPx) && requestedMinimumTextPx > 0
1271
+ && !invalidSemanticText && entries.length > 0;
1272
+ const declared = eligible ? declaredWideReadabilityBudget({
1273
+ viewBoxWidth,
1274
+ viewBoxHeight,
1275
+ minimumSourceTextPx,
1276
+ requestedMinimumTextPx,
1277
+ }) : null;
1278
+ const availableDiagramWidth = declared?.guaranteedSvgWidth ?? DESKTOP_READER_DIAGRAM_WIDTH;
1279
+ const budgetBasis = declared ? 'recognized-declared-wide' : 'legacy-930';
1280
+ const scale = Number.isFinite(viewBoxWidth) && viewBoxWidth > 0
1281
+ ? Math.min(1, availableDiagramWidth / viewBoxWidth) : Number.NaN;
1282
+ const projected = entries.map((entry) => ({
1283
+ ...entry,
1284
+ viewBoxWidth,
1285
+ scale,
1286
+ projectedFontPx: projectedNodeTextPx(entry.sourceFontPx, viewBoxWidth, availableDiagramWidth),
1287
+ }));
1288
+ const worst = projected.reduce((current, entry) => (
1289
+ !current || entry.projectedFontPx < current.projectedFontPx ? entry : current
1290
+ ), null);
1291
+ const projectedMinimumTextPx = Number.isFinite(worst?.projectedFontPx) ? worst.projectedFontPx : null;
1292
+ const hardFloorMet = Number.isFinite(worst?.projectedFontPx)
1293
+ ? worst.projectedFontPx >= MIN_PROJECTED_NODE_TEXT_PX : null;
1294
+ const requestedTargetMet = worst && Number.isFinite(requestedMinimumTextPx)
1295
+ ? worst.projectedFontPx >= requestedMinimumTextPx : null;
1296
+ const evidence = {
1297
+ budgetBasis,
1298
+ readerContract: contract,
1299
+ availableDiagramWidth,
1300
+ actualBudgetPx: availableDiagramWidth,
1301
+ ...(declared ? {
1302
+ actualReaderWidth: declared.actualReaderWidth,
1303
+ desiredReaderWidth: declared.desiredReaderWidth,
1304
+ viewportCap: declared.viewportCap,
1305
+ limit: declared.limit,
1306
+ } : { limit: 'legacy' }),
1307
+ requestedTargetPx: Number.isFinite(requestedMinimumTextPx) ? requestedMinimumTextPx : null,
1308
+ requestedTargetMet,
1309
+ hardFloorPx: MIN_PROJECTED_NODE_TEXT_PX,
1310
+ hardFloorMet,
1311
+ minimumOwner: worst?.owner || null,
1312
+ minimumSourceTextPx: worst?.sourceFontPx ?? null,
1313
+ minimumProjectedTextPx: projectedMinimumTextPx,
1314
+ semanticTextCount: entries.length,
1315
+ };
1316
+ return { evidence, issue: worst && hardFloorMet === false ? worst : null };
1317
+ }
1318
+
1319
+ function estimatedTextWidth(text, fontSize) {
1320
+ let units = 0;
1321
+ for (const char of text) units += char.charCodeAt(0) > 255 ? 1.8 : 0.62;
1322
+ return Math.max(fontSize, units * fontSize);
1323
+ }
1324
+
1325
+ function properSegmentIntersection(a, b, c, d) {
1326
+ const abC = crossProduct(a, b, c);
1327
+ const abD = crossProduct(a, b, d);
1328
+ const cdA = crossProduct(c, d, a);
1329
+ const cdB = crossProduct(c, d, b);
1330
+ const epsilon = 1e-9;
1331
+ const opposite = (left, right) => (left > epsilon && right < -epsilon) || (left < -epsilon && right > epsilon);
1332
+ if (!opposite(abC, abD) || !opposite(cdA, cdB)) return null;
1333
+ const denominator = (a[0] - b[0]) * (c[1] - d[1]) - (a[1] - b[1]) * (c[0] - d[0]);
1334
+ if (Math.abs(denominator) < epsilon) return null;
1335
+ const ab = a[0] * b[1] - a[1] * b[0];
1336
+ const cd = c[0] * d[1] - c[1] * d[0];
1337
+ return [
1338
+ (ab * (c[0] - d[0]) - (a[0] - b[0]) * cd) / denominator,
1339
+ (ab * (c[1] - d[1]) - (a[1] - b[1]) * cd) / denominator,
1340
+ ];
1341
+ }
1342
+
1343
+ function crossProduct(a, b, c) {
1344
+ return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
1345
+ }
1346
+
1347
+ function segmentIntersectsBox(segment, box) {
1348
+ const { start, end } = segment;
1349
+ if (pointInsideBox(start, box) || pointInsideBox(end, box)) return true;
1350
+ const edges = [
1351
+ [[box.x1, box.y1], [box.x2, box.y1]],
1352
+ [[box.x2, box.y1], [box.x2, box.y2]],
1353
+ [[box.x2, box.y2], [box.x1, box.y2]],
1354
+ [[box.x1, box.y2], [box.x1, box.y1]],
1355
+ ];
1356
+ return edges.some(([a, b]) => segmentsIntersect(start, end, a, b));
1357
+ }
1358
+
1359
+ function segmentsIntersect(a, b, c, d) {
1360
+ const o1 = orientation(a, b, c);
1361
+ const o2 = orientation(a, b, d);
1362
+ const o3 = orientation(c, d, a);
1363
+ const o4 = orientation(c, d, b);
1364
+
1365
+ if (o1 !== o2 && o3 !== o4) return true;
1366
+ if (o1 === 0 && onSegment(a, c, b)) return true;
1367
+ if (o2 === 0 && onSegment(a, d, b)) return true;
1368
+ if (o3 === 0 && onSegment(c, a, d)) return true;
1369
+ if (o4 === 0 && onSegment(c, b, d)) return true;
1370
+ return false;
1371
+ }
1372
+
1373
+ function orientation(a, b, c) {
1374
+ const value = (b[1] - a[1]) * (c[0] - b[0]) - (b[0] - a[0]) * (c[1] - b[1]);
1375
+ if (Math.abs(value) < 1e-9) return 0;
1376
+ return value > 0 ? 1 : 2;
1377
+ }
1378
+
1379
+ function onSegment(a, b, c) {
1380
+ return b[0] <= Math.max(a[0], c[0]) + 1e-9
1381
+ && b[0] + 1e-9 >= Math.min(a[0], c[0])
1382
+ && b[1] <= Math.max(a[1], c[1]) + 1e-9
1383
+ && b[1] + 1e-9 >= Math.min(a[1], c[1]);
1384
+ }
1385
+
1386
+ function pointInsideBox(point, box) {
1387
+ return point[0] >= box.x1 && point[0] <= box.x2 && point[1] >= box.y1 && point[1] <= box.y2;
1388
+ }
1389
+
1390
+ function padBox(box, padding) {
1391
+ return {
1392
+ ...box,
1393
+ x1: box.x1 - padding,
1394
+ y1: box.y1 - padding,
1395
+ x2: box.x2 + padding,
1396
+ y2: box.y2 + padding,
1397
+ };
1398
+ }
1399
+
1400
+ // Only geometry/numeric attributes are scanned: authored prose such as node
1401
+ // tags, <title> text, aria-labels, or data-* attributes may legitimately
1402
+ // mention "NaN" or "Infinity" without any coordinate being non-finite.
1403
+ function collectNonFiniteAttrs(svg) {
1404
+ const details = [];
1405
+ const stack = [];
1406
+ for (const match of svg.matchAll(SVG_TAG_TOKEN)) {
1407
+ if (!match[2]) continue;
1408
+ const element = match[2].toLowerCase();
1409
+ if (match[1]) {
1410
+ const index = stack.map(entry => entry.element).lastIndexOf(element);
1411
+ if (index >= 0) stack.length = index;
1412
+ continue;
1413
+ }
1414
+ const parent = stack[stack.length - 1];
1415
+ const inSvg = element === 'svg'
1416
+ || Boolean(parent?.inSvg && !SVG_HTML_INTEGRATION_POINTS.has(parent.element));
1417
+ if (inSvg) {
1418
+ for (const [name, value] of attrEntries(match[0])) {
1419
+ if (!isNumericAttr(element, name) || !NON_FINITE_TOKEN.test(decodeNumericReferences(value))) continue;
1420
+ details.push(`${match[2]} ${name}="${value}"`);
1421
+ }
1422
+ }
1423
+ // HTML void elements do not open a context; SVG self-closing tags do not
1424
+ // either. A nested <svg> inside foreignObject restores SVG checking.
1425
+ if (!(inSvg ? /\/\s*>$/.test(match[0]) : HTML_VOID_ELEMENTS.has(element))) {
1426
+ stack.push({ element, inSvg });
1427
+ }
1428
+ }
1429
+ return details;
1430
+ }
1431
+
1432
+ function decodeNumericReferences(value) {
1433
+ // Numeric references can encode every letter of NaN/Infinity/undefined.
1434
+ // Decode once, locally: other checks and diagnostic evidence keep raw values.
1435
+ return value.replace(/&#(?:x([0-9a-f]+)|([0-9]+));?/gi, (_, hex, decimal) => {
1436
+ const point = Number.parseInt(hex || decimal, hex ? 16 : 10);
1437
+ return point > 0 && point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff)
1438
+ ? String.fromCodePoint(point) : '\uFFFD';
1439
+ });
1440
+ }
1441
+
1442
+ function isNumericAttr(elementName, attrName) {
1443
+ const normalizedAttr = attrName.toLowerCase();
1444
+ return NUMERIC_ATTRS.has(normalizedAttr)
1445
+ || ELEMENT_NUMERIC_ATTRS.get(elementName.toLowerCase())?.has(normalizedAttr);
1446
+ }
1447
+
1448
+ function attrEntries(tag) {
1449
+ return [...tag.matchAll(HTML_ATTRIBUTE)].map((match) => [
1450
+ match[1],
1451
+ match[2] ?? match[3] ?? match[4],
1452
+ ]);
1453
+ }
1454
+
1455
+ function parseAttrs(tag) {
1456
+ return Object.fromEntries(attrEntries(tag));
1457
+ }
1458
+
1459
+ function numberAttr(attrs, name) {
1460
+ return Number.parseFloat(attrs[name]);
1461
+ }
1462
+
1463
+ function isPoint(point) {
1464
+ return Array.isArray(point) && point.length === 2 && point.every(Number.isFinite);
1465
+ }
1466
+
1467
+ function stripTags(value) {
1468
+ return value.replace(/<[^>]*>/g, '');
1469
+ }