dsh-archify-projects 0.0.0-stage → 0.1.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 (148) hide show
  1. package/README.md +204 -2
  2. package/cordis.patch.yml +99 -0
  3. package/dist/client.js +2261 -0
  4. package/dist/client.js.map +1 -0
  5. package/docs/CONTAINER.md +197 -0
  6. package/docs/DESIGN.md +905 -0
  7. package/docs/HANDOVER.md +391 -0
  8. package/docs/PUBLISH.md +107 -0
  9. package/icon.svg +12 -0
  10. package/lib/host/archify-skill.js +116 -0
  11. package/lib/host/archify-skill.js.map +1 -0
  12. package/lib/host/config.js +95 -0
  13. package/lib/host/config.js.map +1 -0
  14. package/lib/host/context.js +28 -0
  15. package/lib/host/context.js.map +1 -0
  16. package/lib/host/git.js +185 -0
  17. package/lib/host/git.js.map +1 -0
  18. package/lib/host/http.js +184 -0
  19. package/lib/host/http.js.map +1 -0
  20. package/lib/host/index.js +90 -0
  21. package/lib/host/index.js.map +1 -0
  22. package/lib/host/policy.js +124 -0
  23. package/lib/host/policy.js.map +1 -0
  24. package/lib/host/routes.js +667 -0
  25. package/lib/host/routes.js.map +1 -0
  26. package/lib/host/runner.js +913 -0
  27. package/lib/host/runner.js.map +1 -0
  28. package/lib/host/store.js +370 -0
  29. package/lib/host/store.js.map +1 -0
  30. package/lib/host/workspace.js +65 -0
  31. package/lib/host/workspace.js.map +1 -0
  32. package/lib/shared/format.js +138 -0
  33. package/lib/shared/format.js.map +1 -0
  34. package/lib/shared/prompt.js +257 -0
  35. package/lib/shared/prompt.js.map +1 -0
  36. package/lib/shared/protocol.js +31 -0
  37. package/lib/shared/protocol.js.map +1 -0
  38. package/lib/shared/types.js +173 -0
  39. package/lib/shared/types.js.map +1 -0
  40. package/locale/en.json +6 -0
  41. package/locale/zh.json +6 -0
  42. package/package.json +98 -4
  43. package/skills/archify/LICENSE +22 -0
  44. package/skills/archify/SKILL.md +109 -0
  45. package/skills/archify/THIRD_PARTY_NOTICES.md +69 -0
  46. package/skills/archify/assets/JetBrainsMono-OFL.txt +93 -0
  47. package/skills/archify/assets/template.html +13709 -0
  48. package/skills/archify/bin/archify.mjs +6936 -0
  49. package/skills/archify/bin/delivery-update.mjs +91 -0
  50. package/skills/archify/bin/finalize.mjs +895 -0
  51. package/skills/archify/bin/open-artifact.mjs +146 -0
  52. package/skills/archify/bin/preview.mjs +969 -0
  53. package/skills/archify/bin/recover-output.mjs +41 -0
  54. package/skills/archify/bin/visual-check.mjs +2667 -0
  55. package/skills/archify/brand-marks/README.md +31 -0
  56. package/skills/archify/brand-marks/catalog.json +131 -0
  57. package/skills/archify/delta/architecture-delta.mjs +1313 -0
  58. package/skills/archify/examples/agent-run.lifecycle.json +55 -0
  59. package/skills/archify/examples/agent-tool-call.workflow.json +74 -0
  60. package/skills/archify/examples/async-job-roundtrip.sequence.json +56 -0
  61. package/skills/archify/examples/brand-aware-delivery.architecture.json +34 -0
  62. package/skills/archify/examples/cache-miss-request.sequence.json +77 -0
  63. package/skills/archify/examples/checkout-platform.base.architecture.json +32 -0
  64. package/skills/archify/examples/checkout-platform.head.architecture.json +32 -0
  65. package/skills/archify/examples/dataflow-product-analytics.html +13820 -0
  66. package/skills/archify/examples/deployment-release.lifecycle.json +48 -0
  67. package/skills/archify/examples/event-stream.dataflow.json +52 -0
  68. package/skills/archify/examples/holiday-planning.workflow.json +143 -0
  69. package/skills/archify/examples/incident-response.workflow.json +59 -0
  70. package/skills/archify/examples/lifecycle-agent-run.html +13765 -0
  71. package/skills/archify/examples/locales/de.json +425 -0
  72. package/skills/archify/examples/locales/es.json +440 -0
  73. package/skills/archify/examples/locales/fr.json +425 -0
  74. package/skills/archify/examples/locales/fr.partial.json +22 -0
  75. package/skills/archify/examples/locales/it.json +425 -0
  76. package/skills/archify/examples/locales/ja.json +425 -0
  77. package/skills/archify/examples/locales/ko.json +440 -0
  78. package/skills/archify/examples/locales/pt.json +425 -0
  79. package/skills/archify/examples/locales/ru.json +425 -0
  80. package/skills/archify/examples/product-analytics.dataflow.json +71 -0
  81. package/skills/archify/examples/production-deployment.architecture.json +51 -0
  82. package/skills/archify/examples/release-delivery.workflow.json +57 -0
  83. package/skills/archify/examples/sequence-cache-miss-request.html +13835 -0
  84. package/skills/archify/examples/web-app-rendered.html +13808 -0
  85. package/skills/archify/examples/web-app.architecture.json +41 -0
  86. package/skills/archify/examples/workflow-agent-tool-call-rendered.html +13826 -0
  87. package/skills/archify/migrations/workflow-v2.mjs +280 -0
  88. package/skills/archify/package.json +17 -0
  89. package/skills/archify/recipes/scenarios.mjs +430 -0
  90. package/skills/archify/references/architecture-layout-repair.md +18 -0
  91. package/skills/archify/references/authoring-contract.md +419 -0
  92. package/skills/archify/references/authoring-defaults.md +41 -0
  93. package/skills/archify/references/brand-marks.md +82 -0
  94. package/skills/archify/references/delivery-contract.md +588 -0
  95. package/skills/archify/references/repository-authoring.md +105 -0
  96. package/skills/archify/references/update-awareness.md +12 -0
  97. package/skills/archify/references/viewer-runtime.md +35 -0
  98. package/skills/archify/renderers/architecture/grid.mjs +62 -0
  99. package/skills/archify/renderers/architecture/labels.mjs +215 -0
  100. package/skills/archify/renderers/architecture/render-architecture.mjs +1001 -0
  101. package/skills/archify/renderers/architecture/routing.mjs +1157 -0
  102. package/skills/archify/renderers/dataflow/README.md +105 -0
  103. package/skills/archify/renderers/dataflow/render-dataflow.mjs +544 -0
  104. package/skills/archify/renderers/lifecycle/README.md +171 -0
  105. package/skills/archify/renderers/lifecycle/grid-routing.mjs +429 -0
  106. package/skills/archify/renderers/lifecycle/render-lifecycle.mjs +938 -0
  107. package/skills/archify/renderers/sequence/README.md +129 -0
  108. package/skills/archify/renderers/sequence/render-sequence.mjs +526 -0
  109. package/skills/archify/renderers/shared/atomic-output.mjs +1982 -0
  110. package/skills/archify/renderers/shared/brand-marks.mjs +670 -0
  111. package/skills/archify/renderers/shared/cli.mjs +451 -0
  112. package/skills/archify/renderers/shared/desktop-readability.mjs +132 -0
  113. package/skills/archify/renderers/shared/diagnostics.mjs +173 -0
  114. package/skills/archify/renderers/shared/engineering-profiles.mjs +157 -0
  115. package/skills/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  116. package/skills/archify/renderers/shared/generated-validators.mjs +134 -0
  117. package/skills/archify/renderers/shared/geometry.mjs +1941 -0
  118. package/skills/archify/renderers/shared/i18n.mjs +603 -0
  119. package/skills/archify/renderers/shared/layout-report.mjs +42 -0
  120. package/skills/archify/renderers/shared/legend.mjs +217 -0
  121. package/skills/archify/renderers/shared/output-path.mjs +440 -0
  122. package/skills/archify/renderers/shared/path-semantics.mjs +1017 -0
  123. package/skills/archify/renderers/shared/portable-path.mjs +408 -0
  124. package/skills/archify/renderers/shared/repository-evidence.mjs +352 -0
  125. package/skills/archify/renderers/shared/repository-location.mjs +59 -0
  126. package/skills/archify/renderers/shared/route-quality.mjs +614 -0
  127. package/skills/archify/renderers/shared/sidecar-path.mjs +38 -0
  128. package/skills/archify/renderers/shared/spatial-grid.mjs +66 -0
  129. package/skills/archify/renderers/shared/text-fit.mjs +105 -0
  130. package/skills/archify/renderers/shared/utils.mjs +254 -0
  131. package/skills/archify/renderers/shared/validator.mjs +86 -0
  132. package/skills/archify/renderers/workflow/README.md +301 -0
  133. package/skills/archify/renderers/workflow/render-workflow.mjs +37 -0
  134. package/skills/archify/renderers/workflow/workflow-compiler.mjs +4913 -0
  135. package/skills/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  136. package/skills/archify/schemas/README.md +256 -0
  137. package/skills/archify/schemas/architecture.schema.json +152 -0
  138. package/skills/archify/schemas/common.schema.json +181 -0
  139. package/skills/archify/schemas/dataflow.schema.json +254 -0
  140. package/skills/archify/schemas/lifecycle.schema.json +280 -0
  141. package/skills/archify/schemas/sequence.schema.json +234 -0
  142. package/skills/archify/schemas/workflow.schema.json +439 -0
  143. package/skills/archify/scripts/check-render-output.mjs +1388 -0
  144. package/skills/archify/scripts/check-update.mjs +1710 -0
  145. package/skills/archify/scripts/delivery-update-child.mjs +23 -0
  146. package/skills/archify/scripts/render-examples.mjs +26 -0
  147. package/skills/archify/scripts/update-contract.mjs +182 -0
  148. package/skills/archify/skill-release.json +10 -0
@@ -0,0 +1,614 @@
1
+ import { recordDiagnostic } from './diagnostics.mjs';
2
+ import {
3
+ asArray,
4
+ isFinitePoint,
5
+ normalizeRoutePoints,
6
+ properSegmentIntersection,
7
+ segmentIntersectsRect,
8
+ } from './geometry.mjs';
9
+
10
+ const DEFAULTS = Object.freeze({
11
+ clearance: 2,
12
+ minimumDetourRatio: 2.5,
13
+ minimumExcessLengthPx: 200,
14
+ minimumEmptyExcursionPx: 96,
15
+ maximumObstacleCount: 80,
16
+ sharedCorridorMinimumPx: 32,
17
+ });
18
+
19
+ const OUTWARD = Object.freeze({
20
+ left: [-1, 0],
21
+ right: [1, 0],
22
+ top: [0, -1],
23
+ bottom: [0, 1],
24
+ });
25
+
26
+ function rounded(value) {
27
+ return Math.round(value * 100) / 100;
28
+ }
29
+
30
+ function pointKey(point) {
31
+ return `${point[0]}\u0000${point[1]}`;
32
+ }
33
+
34
+ class MinHeap {
35
+ constructor() {
36
+ this.entries = [];
37
+ }
38
+
39
+ push(key, distance) {
40
+ const entry = { key, distance };
41
+ this.entries.push(entry);
42
+ let index = this.entries.length - 1;
43
+ while (index > 0) {
44
+ const parent = Math.floor((index - 1) / 2);
45
+ if (this.entries[parent].distance <= distance) break;
46
+ this.entries[index] = this.entries[parent];
47
+ index = parent;
48
+ }
49
+ this.entries[index] = entry;
50
+ }
51
+
52
+ pop() {
53
+ if (!this.entries.length) return null;
54
+ const first = this.entries[0];
55
+ const last = this.entries.pop();
56
+ if (!this.entries.length) return first;
57
+ let index = 0;
58
+ while (true) {
59
+ const left = index * 2 + 1;
60
+ const right = left + 1;
61
+ if (left >= this.entries.length) break;
62
+ const child = right < this.entries.length
63
+ && this.entries[right].distance < this.entries[left].distance ? right : left;
64
+ if (this.entries[child].distance >= last.distance) break;
65
+ this.entries[index] = this.entries[child];
66
+ index = child;
67
+ }
68
+ this.entries[index] = last;
69
+ return first;
70
+ }
71
+ }
72
+
73
+ function orthogonalLength(points) {
74
+ let total = 0;
75
+ for (let index = 0; index < points.length - 1; index += 1) {
76
+ const [x1, y1] = points[index];
77
+ const [x2, y2] = points[index + 1];
78
+ if (x1 !== x2 && y1 !== y2) return null;
79
+ total += Math.abs(x2 - x1) + Math.abs(y2 - y1);
80
+ }
81
+ return total;
82
+ }
83
+
84
+ function inferredSide(points, endpoint) {
85
+ if (points.length < 2) return null;
86
+ const start = endpoint === 'source' ? points[0] : points.at(-2);
87
+ const end = endpoint === 'source' ? points[1] : points.at(-1);
88
+ const dx = end[0] - start[0];
89
+ const dy = end[1] - start[1];
90
+ if (endpoint === 'source') {
91
+ if (dx > 0 && dy === 0) return 'right';
92
+ if (dx < 0 && dy === 0) return 'left';
93
+ if (dy > 0 && dx === 0) return 'bottom';
94
+ if (dy < 0 && dx === 0) return 'top';
95
+ } else {
96
+ if (dx > 0 && dy === 0) return 'left';
97
+ if (dx < 0 && dy === 0) return 'right';
98
+ if (dy > 0 && dx === 0) return 'top';
99
+ if (dy < 0 && dx === 0) return 'bottom';
100
+ }
101
+ return null;
102
+ }
103
+
104
+ function moveOutward(point, side, distance) {
105
+ const [dx, dy] = OUTWARD[side] || [0, 0];
106
+ return [point[0] + dx * distance, point[1] + dy * distance];
107
+ }
108
+
109
+ function expandedRect(rect, clearance) {
110
+ return {
111
+ id: rect.id,
112
+ x: rect.x - clearance,
113
+ y: rect.y - clearance,
114
+ width: rect.width + clearance * 2,
115
+ height: rect.height + clearance * 2,
116
+ };
117
+ }
118
+
119
+ function boundsForRects(rects) {
120
+ const usable = [...rects].filter((rect) => (
121
+ rect && isFinitePoint(rect.x, rect.y, rect.width, rect.height)
122
+ && rect.width >= 0 && rect.height >= 0
123
+ ));
124
+ if (!usable.length) return null;
125
+ const left = Math.min(...usable.map((rect) => rect.x));
126
+ const top = Math.min(...usable.map((rect) => rect.y));
127
+ const right = Math.max(...usable.map((rect) => rect.x + rect.width));
128
+ const bottom = Math.max(...usable.map((rect) => rect.y + rect.height));
129
+ return { left, top, right, bottom, width: right - left, height: bottom - top };
130
+ }
131
+
132
+ function boundsForPoints(points) {
133
+ if (!points.length) return null;
134
+ const xs = points.map(([x]) => x);
135
+ const ys = points.map(([, y]) => y);
136
+ const left = Math.min(...xs);
137
+ const top = Math.min(...ys);
138
+ const right = Math.max(...xs);
139
+ const bottom = Math.max(...ys);
140
+ return { left, top, right, bottom, width: right - left, height: bottom - top };
141
+ }
142
+
143
+ function outsideExcursion(routeBounds, contentBounds) {
144
+ if (!routeBounds || !contentBounds) return null;
145
+ const sides = {
146
+ left: Math.max(0, contentBounds.left - routeBounds.left),
147
+ top: Math.max(0, contentBounds.top - routeBounds.top),
148
+ right: Math.max(0, routeBounds.right - contentBounds.right),
149
+ bottom: Math.max(0, routeBounds.bottom - contentBounds.bottom),
150
+ };
151
+ return { ...sides, maximum: Math.max(...Object.values(sides)) };
152
+ }
153
+
154
+ function pointDistanceFromRect(point, rect) {
155
+ const dx = Math.max(rect.x - point[0], 0, point[0] - (rect.x + rect.width));
156
+ const dy = Math.max(rect.y - point[1], 0, point[1] - (rect.y + rect.height));
157
+ return dx + dy;
158
+ }
159
+
160
+ function emptyControlPointClearance(points, contentRects) {
161
+ const controls = points.slice(1, -1);
162
+ const rects = [...contentRects].filter((rect) => (
163
+ rect && isFinitePoint(rect.x, rect.y, rect.width, rect.height)
164
+ && rect.width >= 0 && rect.height >= 0
165
+ ));
166
+ if (!controls.length || !rects.length) return null;
167
+ const distances = controls.map((point) => Math.min(
168
+ ...rects.map((rect) => pointDistanceFromRect(point, rect)),
169
+ ));
170
+ const maximum = Math.max(...distances);
171
+ return { maximum, point: controls[distances.indexOf(maximum)] };
172
+ }
173
+
174
+ function pointBlocked(point, obstacles) {
175
+ return obstacles.some((rect) => (
176
+ point[0] >= rect.x && point[0] <= rect.x + rect.width
177
+ && point[1] >= rect.y && point[1] <= rect.y + rect.height
178
+ ));
179
+ }
180
+
181
+ function segmentBlocked(start, end, obstacles) {
182
+ return obstacles.some((rect) => segmentIntersectsRect({ start, end }, rect));
183
+ }
184
+
185
+ function segmentConflictsWithAvoided(start, end, avoidedSegments, minimumOverlapPx, allowCrossings) {
186
+ return avoidedSegments.some((segment) => (
187
+ (!allowCrossings && (properSegmentIntersection(start, end, segment.start, segment.end)
188
+ || orthogonalTouchOnAvoidedInterior(start, end, segment.start, segment.end)))
189
+ || collinearOverlap(start, end, segment.start, segment.end) >= minimumOverlapPx
190
+ ));
191
+ }
192
+
193
+ function orthogonalTouchOnAvoidedInterior(start, end, avoidedStart, avoidedEnd) {
194
+ const epsilon = 0.0001;
195
+ const candidateHorizontal = Math.abs(start[1] - end[1]) <= epsilon;
196
+ const candidateVertical = Math.abs(start[0] - end[0]) <= epsilon;
197
+ const avoidedHorizontal = Math.abs(avoidedStart[1] - avoidedEnd[1]) <= epsilon;
198
+ const avoidedVertical = Math.abs(avoidedStart[0] - avoidedEnd[0]) <= epsilon;
199
+ if (candidateHorizontal && avoidedVertical) {
200
+ const x = avoidedStart[0];
201
+ const y = start[1];
202
+ return x >= Math.min(start[0], end[0]) - epsilon
203
+ && x <= Math.max(start[0], end[0]) + epsilon
204
+ && y > Math.min(avoidedStart[1], avoidedEnd[1]) + epsilon
205
+ && y < Math.max(avoidedStart[1], avoidedEnd[1]) - epsilon;
206
+ }
207
+ if (candidateVertical && avoidedHorizontal) {
208
+ const x = start[0];
209
+ const y = avoidedStart[1];
210
+ return y >= Math.min(start[1], end[1]) - epsilon
211
+ && y <= Math.max(start[1], end[1]) + epsilon
212
+ && x > Math.min(avoidedStart[0], avoidedEnd[0]) + epsilon
213
+ && x < Math.max(avoidedStart[0], avoidedEnd[0]) - epsilon;
214
+ }
215
+ return false;
216
+ }
217
+
218
+ function pointOnSegmentInterior(point, start, end) {
219
+ const epsilon = 0.0001;
220
+ const cross = (end[0] - start[0]) * (point[1] - start[1])
221
+ - (end[1] - start[1]) * (point[0] - start[0]);
222
+ if (Math.abs(cross) > epsilon) return false;
223
+ const dot = (point[0] - start[0]) * (point[0] - end[0])
224
+ + (point[1] - start[1]) * (point[1] - end[1]);
225
+ return dot < -epsilon;
226
+ }
227
+
228
+ function pointOnAvoidedInterior(point, avoidedSegments) {
229
+ return avoidedSegments.some((segment) => (
230
+ pointOnSegmentInterior(point, segment.start, segment.end)
231
+ ));
232
+ }
233
+
234
+ function writeGridMetrics(metrics, patch) {
235
+ if (!metrics || typeof metrics !== 'object') return;
236
+ Object.assign(metrics, patch);
237
+ }
238
+
239
+ export function shortestOrthogonalGridRoute({
240
+ start,
241
+ end,
242
+ points,
243
+ obstacles,
244
+ fromSide,
245
+ toSide,
246
+ clearance,
247
+ maximumObstacleCount,
248
+ endpointStubPx = clearance + 2,
249
+ maximumGridNodes = Infinity,
250
+ avoidedSegments = [],
251
+ allowAvoidedCrossings = false,
252
+ minimumAvoidedOverlapPx = 8,
253
+ routeSeparationPx = 8,
254
+ minimumSegmentPx = 8,
255
+ borderSegments = [],
256
+ bendPenaltyPx = 0,
257
+ metrics,
258
+ }) {
259
+ writeGridMetrics(metrics, {
260
+ status: 'initializing',
261
+ maximumGridNodes,
262
+ obstacleCount: 0,
263
+ avoidedSegmentCount: 0,
264
+ coordinateCount: 0,
265
+ candidateNodeCount: 0,
266
+ usableNodeCount: 0,
267
+ graphEdgeCount: 0,
268
+ visitedNodeCount: 0,
269
+ });
270
+ if (!OUTWARD[fromSide] || !OUTWARD[toSide]) {
271
+ writeGridMetrics(metrics, { status: 'unsupported-endpoint-side' });
272
+ return null;
273
+ }
274
+ const startStub = moveOutward(start, fromSide, endpointStubPx);
275
+ const endStub = moveOutward(end, toSide, endpointStubPx);
276
+ // The graph may legally leave the initial endpoint bounds to find a clear
277
+ // corridor. Keep every bounded obstacle and occupied relationship visible
278
+ // to that search; filtering them against the initial box lets a detour walk
279
+ // straight through geometry that only becomes relevant after it leaves the
280
+ // box. The explicit obstacle/node budgets below keep this deterministic.
281
+ const expanded = [...obstacles]
282
+ .filter((rect) => rect && isFinitePoint(rect.x, rect.y, rect.width, rect.height))
283
+ .map((rect) => expandedRect(rect, clearance));
284
+ const relevantAvoidedSegments = [...avoidedSegments]
285
+ .filter((segment) => segment?.start && segment?.end);
286
+ // Frame borders may be crossed perpendicularly but never borrowed as a
287
+ // corridor: the composition gate rejects any collinear run along them.
288
+ const relevantBorderSegments = [...borderSegments]
289
+ .filter((segment) => segment?.start && segment?.end);
290
+ writeGridMetrics(metrics, {
291
+ obstacleCount: expanded.length,
292
+ avoidedSegmentCount: relevantAvoidedSegments.length,
293
+ });
294
+ if (expanded.length > maximumObstacleCount) {
295
+ writeGridMetrics(metrics, { status: 'obstacle-budget-exceeded' });
296
+ return null;
297
+ }
298
+
299
+ const xs = new Set([startStub[0], endStub[0], ...points.map(([x]) => x)]);
300
+ const ys = new Set([startStub[1], endStub[1], ...points.map(([, y]) => y)]);
301
+ for (const rect of expanded) {
302
+ xs.add(rect.x - 1);
303
+ xs.add(rect.x + rect.width + 1);
304
+ ys.add(rect.y - 1);
305
+ ys.add(rect.y + rect.height + 1);
306
+ }
307
+ for (const segment of relevantAvoidedSegments) {
308
+ const [segmentStart, segmentEnd] = [segment.start, segment.end];
309
+ xs.add(segmentStart[0]);
310
+ xs.add(segmentEnd[0]);
311
+ ys.add(segmentStart[1]);
312
+ ys.add(segmentEnd[1]);
313
+ if (Math.abs(segmentStart[0] - segmentEnd[0]) <= 0.0001) {
314
+ xs.add(segmentStart[0] - routeSeparationPx);
315
+ xs.add(segmentStart[0] + routeSeparationPx);
316
+ }
317
+ if (Math.abs(segmentStart[1] - segmentEnd[1]) <= 0.0001) {
318
+ ys.add(segmentStart[1] - routeSeparationPx);
319
+ ys.add(segmentStart[1] + routeSeparationPx);
320
+ }
321
+ }
322
+ for (const segment of relevantBorderSegments) {
323
+ if (Math.abs(segment.start[0] - segment.end[0]) <= 0.0001) {
324
+ xs.add(segment.start[0] - routeSeparationPx);
325
+ xs.add(segment.start[0] + routeSeparationPx);
326
+ }
327
+ if (Math.abs(segment.start[1] - segment.end[1]) <= 0.0001) {
328
+ ys.add(segment.start[1] - routeSeparationPx);
329
+ ys.add(segment.start[1] + routeSeparationPx);
330
+ }
331
+ }
332
+ // Grid lines closer than a readable segment would let the search emit a
333
+ // micro jog between two obstacle edges; keep the endpoint stubs and coalesce
334
+ // the rest so every turn the route can take is at least one segment long.
335
+ const coalesce = (values, keep) => values.sort((a, b) => a - b).filter((value, index, sorted) => (
336
+ index === 0 || keep.has(value) || value - sorted[index - 1] >= minimumSegmentPx
337
+ ));
338
+ const orderedX = coalesce([...xs], new Set([startStub[0], endStub[0]]));
339
+ const orderedY = coalesce([...ys], new Set([startStub[1], endStub[1]]));
340
+ const candidateNodeCount = orderedX.length * orderedY.length;
341
+ writeGridMetrics(metrics, {
342
+ coordinateCount: orderedX.length + orderedY.length,
343
+ candidateNodeCount,
344
+ });
345
+ if (candidateNodeCount > maximumGridNodes) {
346
+ writeGridMetrics(metrics, { status: 'node-budget-exceeded' });
347
+ return null;
348
+ }
349
+ const nodes = new Map();
350
+ for (const x of orderedX) {
351
+ for (const y of orderedY) {
352
+ const point = [x, y];
353
+ if (!pointBlocked(point, expanded)
354
+ && (allowAvoidedCrossings || !pointOnAvoidedInterior(point, relevantAvoidedSegments))) {
355
+ nodes.set(pointKey(point), point);
356
+ }
357
+ }
358
+ }
359
+ writeGridMetrics(metrics, { usableNodeCount: nodes.size });
360
+ if (!nodes.has(pointKey(startStub)) || !nodes.has(pointKey(endStub))) {
361
+ writeGridMetrics(metrics, { status: 'endpoint-blocked' });
362
+ return null;
363
+ }
364
+
365
+ const adjacency = new Map([...nodes.keys()].map((key) => [key, []]));
366
+ let graphEdgeCount = 0;
367
+ const connectLine = (line, axis) => {
368
+ for (let index = 0; index < line.length - 1; index += 1) {
369
+ const left = line[index];
370
+ const right = line[index + 1];
371
+ if (segmentBlocked(left, right, expanded)) continue;
372
+ if (segmentConflictsWithAvoided(
373
+ left,
374
+ right,
375
+ relevantAvoidedSegments,
376
+ minimumAvoidedOverlapPx,
377
+ allowAvoidedCrossings,
378
+ )) continue;
379
+ if (relevantBorderSegments.some((segment) => (
380
+ collinearOverlap(left, right, segment.start, segment.end) > 0.0001
381
+ ))) continue;
382
+ const distance = Math.abs(right[0] - left[0]) + Math.abs(right[1] - left[1]);
383
+ const leftKey = pointKey(left);
384
+ const rightKey = pointKey(right);
385
+ adjacency.get(leftKey).push([rightKey, distance, axis === 'h' ? 'R' : 'D']);
386
+ adjacency.get(rightKey).push([leftKey, distance, axis === 'h' ? 'L' : 'U']);
387
+ graphEdgeCount += 1;
388
+ }
389
+ };
390
+ for (const y of orderedY) {
391
+ connectLine(orderedX.map((x) => nodes.get(pointKey([x, y]))).filter(Boolean), 'h');
392
+ }
393
+ for (const x of orderedX) {
394
+ connectLine(orderedY.map((y) => nodes.get(pointKey([x, y]))).filter(Boolean), 'v');
395
+ }
396
+ writeGridMetrics(metrics, { graphEdgeCount });
397
+
398
+ // The search state carries the incoming direction so a turn can cost extra
399
+ // and a reversal is never taken: the pure shortest path hugs every obstacle
400
+ // corner with a staircase of short jogs, while a bend-penalised one takes
401
+ // the same corridor in a few long strokes. The first stub already leaves
402
+ // the endpoint along its side and the last one arrives along the end side.
403
+ const directionOf = ([dx, dy]) => (dx > 0 ? 'R' : dx < 0 ? 'L' : dy > 0 ? 'D' : 'U');
404
+ const opposite = { R: 'L', L: 'R', D: 'U', U: 'D' };
405
+ const stateKey = (key, direction) => `${key}|${direction}`;
406
+ const sourceAxis = directionOf(OUTWARD[fromSide]);
407
+ const targetAxis = opposite[directionOf(OUTWARD[toSide])];
408
+ const source = pointKey(startStub);
409
+ const target = pointKey(endStub);
410
+ const sourceState = stateKey(source, sourceAxis);
411
+ const distances = new Map([[sourceState, 0]]);
412
+ const previous = new Map();
413
+ const queue = new MinHeap();
414
+ queue.push(sourceState, 0);
415
+ let visitedNodeCount = 0;
416
+ let targetState = null;
417
+ while (queue.entries.length) {
418
+ const next = queue.pop();
419
+ const current = next.key;
420
+ const currentDistance = next.distance;
421
+ if (currentDistance !== distances.get(current)) continue;
422
+ visitedNodeCount += 1;
423
+ const [currentNode, currentAxis] = current.split('|');
424
+ if (currentNode === target) {
425
+ // Arriving on the wrong axis costs one final turn onto the end stub.
426
+ const arrival = currentDistance + (currentAxis === targetAxis ? 0 : bendPenaltyPx);
427
+ if (targetState == null || arrival < targetState.distance) {
428
+ targetState = { key: current, distance: arrival };
429
+ }
430
+ if (currentAxis === targetAxis || bendPenaltyPx === 0) break;
431
+ continue;
432
+ }
433
+ if (targetState && currentDistance >= targetState.distance) break;
434
+ for (const [neighbor, weight, axis] of adjacency.get(currentNode) || []) {
435
+ if (axis === opposite[currentAxis]) continue;
436
+ const candidate = currentDistance + weight + (axis === currentAxis ? 0 : bendPenaltyPx);
437
+ const neighborState = stateKey(neighbor, axis);
438
+ if (candidate >= (distances.get(neighborState) ?? Infinity)) continue;
439
+ distances.set(neighborState, candidate);
440
+ previous.set(neighborState, current);
441
+ queue.push(neighborState, candidate);
442
+ }
443
+ }
444
+ writeGridMetrics(metrics, { visitedNodeCount });
445
+ if (!targetState) {
446
+ writeGridMetrics(metrics, { status: 'no-route' });
447
+ return null;
448
+ }
449
+ const reversed = [];
450
+ for (let key = targetState.key; key; key = previous.get(key)) {
451
+ reversed.push(nodes.get(key.split('|')[0]));
452
+ if (key === sourceState) break;
453
+ }
454
+ if (pointKey(reversed.at(-1)) !== source) {
455
+ writeGridMetrics(metrics, { status: 'broken-predecessor-chain' });
456
+ return null;
457
+ }
458
+ const shortestPoints = normalizeRoutePoints([start, ...reversed.reverse(), end]);
459
+ writeGridMetrics(metrics, { status: 'routed' });
460
+ return {
461
+ points: shortestPoints,
462
+ length: orthogonalLength(shortestPoints),
463
+ obstacleCount: expanded.length,
464
+ };
465
+ }
466
+
467
+ function collinearOverlap(leftStart, leftEnd, rightStart, rightEnd) {
468
+ if (leftStart[0] === leftEnd[0] && rightStart[0] === rightEnd[0]
469
+ && leftStart[0] === rightStart[0]) {
470
+ return Math.max(0, Math.min(Math.max(leftStart[1], leftEnd[1]), Math.max(rightStart[1], rightEnd[1]))
471
+ - Math.max(Math.min(leftStart[1], leftEnd[1]), Math.min(rightStart[1], rightEnd[1])));
472
+ }
473
+ if (leftStart[1] === leftEnd[1] && rightStart[1] === rightEnd[1]
474
+ && leftStart[1] === rightStart[1]) {
475
+ return Math.max(0, Math.min(Math.max(leftStart[0], leftEnd[0]), Math.max(rightStart[0], rightEnd[0]))
476
+ - Math.max(Math.min(leftStart[0], leftEnd[0]), Math.min(rightStart[0], rightEnd[0])));
477
+ }
478
+ return 0;
479
+ }
480
+
481
+ function segmentOutsideContent(start, end, contentBounds) {
482
+ if (!contentBounds) return false;
483
+ const midpoint = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2];
484
+ return midpoint[0] < contentBounds.left || midpoint[0] > contentBounds.right
485
+ || midpoint[1] < contentBounds.top || midpoint[1] > contentBounds.bottom;
486
+ }
487
+
488
+ function sharesOuterCorridor({ relation, relations, pathFor, points, contentBounds, minimumOverlap }) {
489
+ for (const other of asArray(relations)) {
490
+ if (!other || other === relation) continue;
491
+ const related = relation.from === other.from || relation.from === other.to
492
+ || relation.to === other.from || relation.to === other.to;
493
+ if (!related) continue;
494
+ const otherPoints = normalizeRoutePoints(pathFor(other)?.points || []);
495
+ for (let left = 0; left < points.length - 1; left += 1) {
496
+ if (!segmentOutsideContent(points[left], points[left + 1], contentBounds)) continue;
497
+ for (let right = 0; right < otherPoints.length - 1; right += 1) {
498
+ if (collinearOverlap(points[left], points[left + 1], otherPoints[right], otherPoints[right + 1]) >= minimumOverlap) {
499
+ return true;
500
+ }
501
+ }
502
+ }
503
+ }
504
+ return false;
505
+ }
506
+
507
+ function relationshipSubject(diagramType, relationCollection, relationIndex, relation) {
508
+ return {
509
+ diagramType,
510
+ collection: relationCollection,
511
+ index: relationIndex,
512
+ ...(relation.id ? { id: relation.id } : {}),
513
+ from: relation.from,
514
+ to: relation.to,
515
+ };
516
+ }
517
+
518
+ /**
519
+ * Reject conspicuous authored detours without penalizing routes whose length is
520
+ * explained by opaque-node avoidance or a related shared outer corridor.
521
+ */
522
+ export function cleanRouteDetourProblems({
523
+ relations,
524
+ obstacles,
525
+ contentRects = obstacles,
526
+ endpointIds,
527
+ pathFor,
528
+ fromSideFor,
529
+ toSideFor,
530
+ diagramType,
531
+ relationCollection,
532
+ profile,
533
+ thresholds = {},
534
+ }) {
535
+ if (profile !== 'showcase') return [];
536
+ const policy = { ...DEFAULTS, ...thresholds };
537
+ const obstacleList = [...obstacles];
538
+ const contentBounds = boundsForRects(contentRects);
539
+ const problems = [];
540
+ for (const [relationIndex, relation] of asArray(relations).entries()) {
541
+ if (!relation || !endpointIds?.has(relation.from) || !endpointIds?.has(relation.to)) continue;
542
+ if (!Array.isArray(relation.via) || relation.via.length === 0) continue;
543
+ const points = normalizeRoutePoints(pathFor(relation)?.points || []);
544
+ if (points.length < 3 || !points.every((point) => Array.isArray(point) && isFinitePoint(...point))) continue;
545
+ const actualLength = orthogonalLength(points);
546
+ if (!Number.isFinite(actualLength)) continue;
547
+ const start = points[0];
548
+ const end = points.at(-1);
549
+ const manhattan = Math.abs(end[0] - start[0]) + Math.abs(end[1] - start[1]);
550
+ if (actualLength < manhattan * policy.minimumDetourRatio
551
+ || actualLength - manhattan < policy.minimumExcessLengthPx) continue;
552
+ const routeBounds = boundsForPoints(points);
553
+ const excursion = outsideExcursion(routeBounds, contentBounds);
554
+ const emptyClearance = emptyControlPointClearance(points, obstacleList);
555
+ if (Math.max(excursion?.maximum || 0, emptyClearance?.maximum || 0)
556
+ < policy.minimumEmptyExcursionPx) continue;
557
+ if (sharesOuterCorridor({
558
+ relation,
559
+ relations,
560
+ pathFor,
561
+ points,
562
+ contentBounds,
563
+ minimumOverlap: policy.sharedCorridorMinimumPx,
564
+ })) continue;
565
+
566
+ const fromSide = fromSideFor?.(relation) || inferredSide(points, 'source');
567
+ const toSide = toSideFor?.(relation) || inferredSide(points, 'target');
568
+ const shortest = shortestOrthogonalGridRoute({
569
+ start,
570
+ end,
571
+ points,
572
+ obstacles: obstacleList,
573
+ fromSide,
574
+ toSide,
575
+ clearance: policy.clearance,
576
+ maximumObstacleCount: policy.maximumObstacleCount,
577
+ });
578
+ if (!shortest || !Number.isFinite(shortest.length) || shortest.length <= 0) continue;
579
+ const detourRatio = actualLength / shortest.length;
580
+ const excessLength = actualLength - shortest.length;
581
+ if (detourRatio < policy.minimumDetourRatio || excessLength < policy.minimumExcessLengthPx) continue;
582
+
583
+ const relationId = relation.id ? ` id "${relation.id}"` : '';
584
+ const message = `[composition/excessive-route-detour] ${diagramType} ${relationCollection}[${relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" travels ${Math.round(actualLength)}px, ${rounded(detourRatio)}x the ${Math.round(shortest.length)}px shortest obstacle-clearing orthogonal route, and reaches ${Math.round(excursion.maximum)}px beyond the content bounds — remove the distant via corridor or move it close to the connected content.`;
585
+ const supportedFix = 'remove the distant via points and retry automatic routing, or keep the endpoint sides and move the via corridor near the connected nodes while preserving labels and direction';
586
+ recordDiagnostic({
587
+ code: 'composition/excessive-route-detour',
588
+ severity: 'error',
589
+ message,
590
+ subject: relationshipSubject(diagramType, relationCollection, relationIndex, relation),
591
+ evidence: {
592
+ points,
593
+ actualLengthPx: rounded(actualLength),
594
+ shortestLegalPoints: shortest.points,
595
+ shortestLegalLengthPx: rounded(shortest.length),
596
+ detourRatio: rounded(detourRatio),
597
+ excessLengthPx: rounded(excessLength),
598
+ routeBounds,
599
+ contentBounds,
600
+ emptyExcursionPx: excursion,
601
+ emptyControlPointClearancePx: emptyClearance,
602
+ obstacleCount: shortest.obstacleCount,
603
+ thresholds: {
604
+ minimumDetourRatio: policy.minimumDetourRatio,
605
+ minimumExcessLengthPx: policy.minimumExcessLengthPx,
606
+ minimumEmptyExcursionPx: policy.minimumEmptyExcursionPx,
607
+ },
608
+ },
609
+ supportedFixes: [supportedFix],
610
+ });
611
+ problems.push(message);
612
+ }
613
+ return problems;
614
+ }
@@ -0,0 +1,38 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ const SIDECAR_STEM_NAMESPACE = /\.~archify-[0-9a-f]{64}$/iu;
4
+
5
+ export function sidecarStemNeedsBounding(stem, suffixes) {
6
+ const fits = (value) => value.length <= 255 && Buffer.byteLength(value, 'utf8') <= 255;
7
+ return !suffixes.every((suffix) => fits(`${stem}${suffix}`));
8
+ }
9
+
10
+ export function boundedSidecarStem(stem, suffixes, { force = false, hashDomain } = {}) {
11
+ if (!force && !sidecarStemNeedsBounding(stem, suffixes)
12
+ && !SIDECAR_STEM_NAMESPACE.test(stem)) return stem;
13
+ const hashInput = hashDomain ? `${hashDomain}\0${stem}` : stem;
14
+ const marker = `.~archify-${createHash('sha256').update(hashInput).digest('hex')}`;
15
+ const codePoints = [...stem];
16
+ while (codePoints.length
17
+ && sidecarStemNeedsBounding(`${codePoints.join('')}${marker}`, suffixes)) {
18
+ codePoints.pop();
19
+ }
20
+ return `${codePoints.join('')}${marker}`;
21
+ }
22
+
23
+ export function sidecarStemFromComponent(component) {
24
+ if (component.endsWith('.html')) {
25
+ return {
26
+ stem: component.slice(0, -'.html'.length),
27
+ options: undefined,
28
+ };
29
+ }
30
+ return {
31
+ stem: component,
32
+ options: { force: true, hashDomain: 'full-component' },
33
+ };
34
+ }
35
+
36
+ export function isBoundedSidecarStem(stem) {
37
+ return SIDECAR_STEM_NAMESPACE.test(stem);
38
+ }