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,451 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { applyTemplate, renderCards, esc } from './utils.mjs';
5
+ import { validateSchema } from './validator.mjs';
6
+ import { verifyRepositoryEvidence } from './repository-evidence.mjs';
7
+ import { installRendererDiagnosticBoundary, throwDiagnosticError, throwDiagnosticProblems, recordDiagnostic } from './diagnostics.mjs';
8
+ import { validateEngineeringProfile } from './engineering-profiles.mjs';
9
+ import {
10
+ resolveOutputPath,
11
+ validateAuthoredOutputPath,
12
+ } from './output-path.mjs';
13
+ import {
14
+ captureAtomicOutput,
15
+ captureRegularFileBinding,
16
+ publishRegularFileBinding,
17
+ releaseRegularFileBinding,
18
+ removeOwnedRegularFile,
19
+ verifyAtomicOutput,
20
+ } from './atomic-output.mjs';
21
+ import { resolveLocale, translateMessage, registerLocale, SUPPORTED_LOCALES } from './i18n.mjs';
22
+ import { prepareDiagramBrandMarks } from './brand-marks.mjs';
23
+
24
+ const outputPathGuards = new Map();
25
+ let renderCandidateSequence = 0;
26
+
27
+ // meta.locale is renderer-owned Viewer UI, not authored content.
28
+ // en and zh-CN ship as built-in catalogs.
29
+ // Any other tag needs meta.translations (validated against the English
30
+ // message-key set, layered over English per-key so partial/invalid entries
31
+ // never break rendering) or it falls back to the English Viewer chrome —
32
+ // the same "omit locale, disclose the fallback" contract as before, just
33
+ // resolved from data instead of a hard-coded enum. See i18n.mjs.
34
+ function applyLocaleTranslations(diagramType, diagram) {
35
+ const locale = diagram.meta?.locale;
36
+ if (!locale) return;
37
+ const translations = diagram.meta?.translations;
38
+ if (translations && Object.keys(translations).length) {
39
+ const report = registerLocale(locale, translations);
40
+ if (report.missingKeys.length || report.unknownKeys.length || report.placeholderMismatches.length) {
41
+ recordDiagnostic({
42
+ code: 'i18n/translation-coverage',
43
+ severity: 'warning',
44
+ message: `meta.translations for locale ${JSON.stringify(locale)} covers ${report.coveredKeys}/${report.totalKeys} renderer-owned messages (${Math.round(report.coverage * 100)}%); uncovered keys fall back to English.`,
45
+ subject: { diagramType, path: '/meta/translations' },
46
+ evidence: {
47
+ missingKeys: report.missingKeys.slice(0, 10),
48
+ missingKeysTotal: report.missingKeys.length,
49
+ unknownKeys: report.unknownKeys.slice(0, 10),
50
+ unknownKeysTotal: report.unknownKeys.length,
51
+ placeholderMismatches: report.placeholderMismatches.slice(0, 10),
52
+ placeholderMismatchesTotal: report.placeholderMismatches.length,
53
+ },
54
+ supportedFixes: ['Add the missing keys to meta.translations.', 'Match each translation\'s {placeholders} to the English source string.'],
55
+ });
56
+ // Coverage is a fact about this render, not just a diagnostic-mode
57
+ // artifact: print it to stderr unconditionally so `render`/`deliver`/
58
+ // `validate` disclose the fallback even without ARCHIFY_DIAGNOSTIC_FORMAT.
59
+ console.warn(`archify: meta.translations for locale ${JSON.stringify(locale)} covers ${report.coveredKeys}/${report.totalKeys} renderer-owned messages (${Math.round(report.coverage * 100)}%); uncovered keys fall back to English.`);
60
+ }
61
+ } else if (!SUPPORTED_LOCALES.includes(locale)) {
62
+ recordDiagnostic({
63
+ code: 'i18n/locale-fallback',
64
+ severity: 'warning',
65
+ message: `meta.locale ${JSON.stringify(locale)} has no built-in catalog and no meta.translations; the Viewer chrome and <html lang> fall back to English.`,
66
+ subject: { diagramType, path: '/meta/locale' },
67
+ supportedFixes: ['Supply meta.translations for this locale.', `Use a built-in locale: ${SUPPORTED_LOCALES.join(', ')}.`],
68
+ });
69
+ console.warn(`archify: meta.locale ${JSON.stringify(locale)} has no built-in catalog and no meta.translations; the Viewer chrome and <html lang> fall back to English.`);
70
+ }
71
+ }
72
+
73
+ // Common CLI head: node render-<type>.mjs [input.json] [output.html]
74
+ // Keep this synchronous because callers also use it to establish the guarded
75
+ // output path before testing a last-moment filesystem alias change.
76
+ export function loadDiagram({ rendererDir, diagramType, defaultExample, argv = process.argv }) {
77
+ // Compilers also import this module for SVG helpers. Only CLI execution
78
+ // should install a process-level handler, before reading or validating input.
79
+ installRendererDiagnosticBoundary();
80
+ const skillRoot = path.resolve(rendererDir, '../..');
81
+ const inputPath = path.resolve(argv[2] || path.join(skillRoot, 'examples', defaultExample));
82
+ let input;
83
+ try {
84
+ input = fs.readFileSync(inputPath, 'utf8');
85
+ } catch (error) {
86
+ if (!isFilesystemError(error)) throw error;
87
+ const message = `Input could not be read: ${error.message}`;
88
+ throwDiagnosticError(message, [{
89
+ code: 'input/read', message,
90
+ subject: { input: inputPath },
91
+ evidence: { systemCode: error.code, reason: error.message },
92
+ supportedFixes: ['provide one readable JSON input file'],
93
+ }]);
94
+ }
95
+ let diagram;
96
+ try {
97
+ diagram = JSON.parse(input);
98
+ } catch (error) {
99
+ if (!(error instanceof SyntaxError)) throw error;
100
+ const message = `Input JSON could not be parsed: ${error.message}`;
101
+ throwDiagnosticError(message, [{
102
+ code: 'input/json-parse', message,
103
+ subject: { input: inputPath },
104
+ evidence: { reason: error.message },
105
+ supportedFixes: ['repair the JSON syntax and run validation again'],
106
+ }]);
107
+ }
108
+ const authoredOutput = diagram?.meta?.output;
109
+ if (authoredOutput !== undefined) validateAuthoredOutputPath(authoredOutput);
110
+ validateSchema(diagramType, diagram);
111
+ applyLocaleTranslations(diagramType, diagram);
112
+ validateCrossCollectionContracts(diagramType, diagram);
113
+ validateEngineeringProfile(diagramType, diagram);
114
+ const sourceEvidence = verifyRepositoryEvidence(diagramType, diagram, process.env.ARCHIFY_REPO_ROOT);
115
+ const template = fs.readFileSync(path.join(skillRoot, 'assets/template.html'), 'utf8');
116
+ const outputRequest = {
117
+ requestedOutput: argv[3],
118
+ authoredOutput: diagram.meta?.output,
119
+ defaultOutput: `${diagramType}.html`,
120
+ inputPaths: [inputPath],
121
+ cwd: process.cwd(),
122
+ };
123
+ let outPath;
124
+ try {
125
+ ({ outputPath: outPath } = resolveOutputPath(outputRequest));
126
+ } catch (error) {
127
+ throwOutputError(error, path.resolve(outputRequest.requestedOutput || outputRequest.authoredOutput || outputRequest.defaultOutput));
128
+ }
129
+ outputPathGuards.set(outPath, outputRequest);
130
+ return { diagram, template, outPath, sourceEvidence };
131
+ }
132
+
133
+ // Brand URL capture is the only asynchronous authoring step. Typed renderers
134
+ // opt into it through this wrapper without changing loadDiagram's long-lived
135
+ // synchronous safety contract.
136
+ export async function loadDiagramWithBrandMarks(options) {
137
+ const loaded = loadDiagram(options);
138
+ await prepareDiagramBrandMarks(options.diagramType, loaded.diagram);
139
+ return loaded;
140
+ }
141
+
142
+ const START_TYPES = new Set(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']);
143
+
144
+ function isFilesystemError(error) {
145
+ return typeof error?.code === 'string'
146
+ && typeof error?.syscall === 'string'
147
+ && typeof error?.errno === 'number';
148
+ }
149
+
150
+ function throwOutputError(error, output) {
151
+ if (error?.archifyDiagnostics || !isFilesystemError(error)) throw error;
152
+ const message = `Output could not be written: ${error.message}`;
153
+ throwDiagnosticError(message, [{
154
+ code: 'output/write', message,
155
+ subject: { output },
156
+ evidence: { systemCode: error.code, reason: error.message },
157
+ supportedFixes: ['choose a writable HTML file path and ensure its parent directories can be created'],
158
+ }]);
159
+ }
160
+
161
+ function throwAtomicOutputFailure(result, output) {
162
+ const reason = result.reason || { code: 'unclassified' };
163
+ const changed = result.status === 'different';
164
+ const candidate = reason.code.startsWith('candidate-');
165
+ const nonRegular = ['target-not-regular-file', 'candidate-not-regular-file'].includes(reason.code);
166
+ const hardlinked = ['target-hardlinked', 'candidate-hardlinked'].includes(reason.code);
167
+ const message = changed
168
+ ? 'Output target changed while the rendered artifact was being prepared.'
169
+ : nonRegular
170
+ ? candidate
171
+ ? 'Temporary output candidate is no longer a regular file.'
172
+ : 'Output already exists and is not a regular file.'
173
+ : hardlinked
174
+ ? candidate
175
+ ? 'Temporary output candidate has multiple hard-link names.'
176
+ : 'Output already exists through multiple hard-link names.'
177
+ : 'Output target stability could not be determined safely before commit.';
178
+ throwDiagnosticError(message, [{
179
+ code: changed
180
+ ? 'output/target-changed'
181
+ : nonRegular
182
+ ? 'output/target-not-regular-file'
183
+ : hardlinked
184
+ ? 'output/target-hardlinked'
185
+ : 'output/target-indeterminate',
186
+ message,
187
+ subject: { output },
188
+ evidence: { relation: reason },
189
+ supportedFixes: [hardlinked
190
+ ? 'choose a non-hardlinked output path; atomic replacement cannot update every hard-link name'
191
+ : 'retry after other processes stop replacing or redirecting the output path'],
192
+ }]);
193
+ }
194
+
195
+ function stageRenderedHtml(outputPath, html, mode) {
196
+ for (let attempt = 0; attempt < 100; attempt += 1) {
197
+ renderCandidateSequence += 1;
198
+ const candidatePath = path.join(
199
+ path.dirname(outputPath),
200
+ `.archify-render-${process.pid}-${Date.now().toString(36)}-${renderCandidateSequence}.tmp`,
201
+ );
202
+ let descriptor;
203
+ let identity;
204
+ try {
205
+ const noFollow = process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0);
206
+ descriptor = fs.openSync(
207
+ candidatePath,
208
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow,
209
+ mode ?? 0o666,
210
+ );
211
+ let metadata;
212
+ try {
213
+ metadata = fs.fstatSync(descriptor, { bigint: true });
214
+ } catch (error) {
215
+ // A transient first inspection failure must not strand the exclusive
216
+ // candidate. A successful retry binds cleanup to the still-open file;
217
+ // if both inspections fail, preserving the unknown entry is safer.
218
+ try {
219
+ const retry = fs.fstatSync(descriptor, { bigint: true });
220
+ if (retry.isFile() && retry.ino !== 0n) {
221
+ identity = { device: retry.dev, inode: retry.ino };
222
+ }
223
+ } catch {}
224
+ throw error;
225
+ }
226
+ if (!metadata.isFile() || metadata.ino === 0n) {
227
+ throw new Error('Temporary render candidate identity could not be verified safely.');
228
+ }
229
+ identity = { device: metadata.dev, inode: metadata.ino };
230
+ fs.writeFileSync(descriptor, html);
231
+ // Creation modes are filtered through the process umask. An atomic
232
+ // replacement must retain the exact permissions of an existing target,
233
+ // while a brand-new target should keep normal umask behavior.
234
+ if (mode !== null) fs.fchmodSync(descriptor, mode);
235
+ fs.closeSync(descriptor);
236
+ descriptor = undefined;
237
+ return { candidatePath, identity };
238
+ } catch (error) {
239
+ if (descriptor !== undefined) {
240
+ try { fs.closeSync(descriptor); } catch {}
241
+ }
242
+ if (error.code === 'EEXIST') continue;
243
+ if (identity) {
244
+ const cleanup = removeOwnedRegularFile(candidatePath, identity);
245
+ if (!['removed', 'absent', 'preserved'].includes(cleanup.status)) {
246
+ const cleanupError = new Error(`${error.message}; temporary render candidate cleanup also failed.`);
247
+ cleanupError.cause = error;
248
+ throw cleanupError;
249
+ }
250
+ }
251
+ throw error;
252
+ }
253
+ }
254
+ const error = new Error(`Could not reserve a temporary render candidate beside "${outputPath}".`);
255
+ error.code = 'EEXIST';
256
+ error.errno = -17;
257
+ error.syscall = 'open';
258
+ throw error;
259
+ }
260
+
261
+ // Common CLI tail: fill the template and write the standalone HTML file.
262
+ export function writeDiagram({ outPath, template, diagramType, meta, svg, cards, sourceEvidence = null }) {
263
+ if (!START_TYPES.has(diagramType)) throw new Error(`writeDiagram: unknown diagram type ${JSON.stringify(diagramType)}`);
264
+ const outputGuard = outputPathGuards.get(outPath);
265
+ const html = applyTemplate(template, {
266
+ title: meta.title,
267
+ subtitle: meta.subtitle,
268
+ svg,
269
+ cards: renderCards(cards),
270
+ locale: meta.locale,
271
+ visualPreset: meta.visual_preset || 'classic',
272
+ sourceEvidence,
273
+ });
274
+ let candidatePath;
275
+ let candidateIdentity;
276
+ let candidateBinding;
277
+ try {
278
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
279
+ const outputCapture = captureAtomicOutput(outPath);
280
+ if (outputCapture.status !== 'captured') throwAtomicOutputFailure(outputCapture, outPath);
281
+ const beforeStage = verifyAtomicOutput(outputCapture.snapshot);
282
+ if (beforeStage.status !== 'match') throwAtomicOutputFailure(beforeStage, outPath);
283
+ ({ candidatePath, identity: candidateIdentity } = stageRenderedHtml(
284
+ outputCapture.commitPath,
285
+ html,
286
+ outputCapture.mode,
287
+ ));
288
+
289
+ // The renderer may spend substantial time building HTML after loadDiagram
290
+ // establishes the guard. Re-run it after staging so a last-moment alias
291
+ // cannot redirect the commit onto an input file.
292
+ if (outputGuard) resolveOutputPath(outputGuard);
293
+ const candidateCapture = captureRegularFileBinding(candidatePath, {
294
+ subject: 'candidate',
295
+ expectedSha256: createHash('sha256').update(html).digest('hex'),
296
+ expectedBytes: Buffer.byteLength(html),
297
+ expectedIdentity: candidateIdentity,
298
+ ...(outputCapture.mode === null ? {} : { expectedMode: outputCapture.mode }),
299
+ });
300
+ if (candidateCapture.status !== 'captured') throwAtomicOutputFailure(candidateCapture, outPath);
301
+ candidateBinding = candidateCapture.binding;
302
+ const beforeCommit = verifyAtomicOutput(outputCapture.snapshot);
303
+ if (beforeCommit.status !== 'match') throwAtomicOutputFailure(beforeCommit, outPath);
304
+ const publication = publishRegularFileBinding(
305
+ candidateBinding,
306
+ candidatePath,
307
+ outputCapture.snapshot,
308
+ { subject: 'candidate' },
309
+ );
310
+ if (!['committed', 'committed-with-warning'].includes(publication.status)) {
311
+ throwAtomicOutputFailure(publication, outPath);
312
+ }
313
+ const releasedCandidate = releaseRegularFileBinding(candidateBinding);
314
+ candidateBinding = undefined;
315
+ if (releasedCandidate.status !== 'released') throwAtomicOutputFailure(releasedCandidate, outPath);
316
+ candidatePath = undefined;
317
+ candidateIdentity = undefined;
318
+ } catch (error) {
319
+ throwOutputError(error, outPath);
320
+ } finally {
321
+ outputPathGuards.delete(outPath);
322
+ if (candidateBinding) releaseRegularFileBinding(candidateBinding);
323
+ if (candidatePath && candidateIdentity) {
324
+ const cleanup = removeOwnedRegularFile(candidatePath, candidateIdentity);
325
+ if (!['removed', 'absent', 'preserved'].includes(cleanup.status)) {
326
+ throwAtomicOutputFailure(cleanup, outPath);
327
+ }
328
+ }
329
+ }
330
+ console.log(outPath);
331
+ }
332
+
333
+ const SEMANTIC_COLLECTIONS = {
334
+ architecture: 'components',
335
+ workflow: 'nodes',
336
+ sequence: 'participants',
337
+ dataflow: 'nodes',
338
+ lifecycle: 'states',
339
+ };
340
+
341
+ const RELATIONSHIP_COLLECTIONS = {
342
+ architecture: 'connections',
343
+ workflow: 'edges',
344
+ sequence: 'messages',
345
+ dataflow: 'flows',
346
+ lifecycle: 'transitions',
347
+ };
348
+
349
+ // Relationship IDs are optional for backwards compatibility, but once an
350
+ // author supplies one it becomes the durable identity used by viewer links.
351
+ // Keep uniqueness enforcement in the shared zero-install path so every typed
352
+ // renderer fails the same way even when development dependencies are absent.
353
+ export function validateRelationshipIds(diagramType, diagram) {
354
+ const collection = RELATIONSHIP_COLLECTIONS[diagramType];
355
+ const relationships = collection && Array.isArray(diagram[collection]) ? diagram[collection] : [];
356
+ const seen = new Set();
357
+ const problems = [];
358
+
359
+ relationships.forEach((relationship, index) => {
360
+ if (relationship.id === undefined || relationship.id === null || relationship.id === '') return;
361
+ if (seen.has(relationship.id)) {
362
+ problems.push(`/${collection}/${index}/id duplicates relationship id ${JSON.stringify(relationship.id)}`);
363
+ }
364
+ seen.add(relationship.id);
365
+ });
366
+
367
+ if (problems.length) {
368
+ throwDiagnosticProblems('Relationship identity validation failed', problems, {
369
+ code: 'relationship/duplicate-id',
370
+ subject: { diagramType, collection },
371
+ });
372
+ }
373
+ }
374
+
375
+ // Share relationship-ID semantic checks between the loader
376
+ // and workflow compiler without performing filesystem operations (see #429).
377
+ export function validateCrossCollectionContracts(diagramType, diagram) {
378
+ validateRelationshipIds(diagramType, diagram);
379
+ }
380
+
381
+ // Accessible name for the generated diagram SVG.
382
+ export function svgRootAttrs(meta, explicitQualityProfile) {
383
+ const animation = meta.animation === 'trace' ? ' data-animation="trace"' : '';
384
+ const preset = ` data-preset="${esc(meta.visual_preset || 'classic')}"`;
385
+ const engineeringProfile = meta.engineering_profile
386
+ ? ` data-engineering-profile="${esc(meta.engineering_profile)}"`
387
+ : '';
388
+ const requestedProfile = explicitQualityProfile || process.env.ARCHIFY_QUALITY_PROFILE || meta.quality_profile;
389
+ const qualityProfile = requestedProfile === 'showcase' ? 'showcase' : 'standard';
390
+ const advisory = requestedProfile ? '' : ' data-quality-gates="advisory"';
391
+ return `role="img" lang="${esc(resolveLocale(meta.locale))}" aria-labelledby="archify-diagram-title archify-diagram-description"${animation}${preset}${engineeringProfile} data-quality-profile="${esc(qualityProfile)}"${advisory}`;
392
+ }
393
+
394
+ // Keep the accessible name inside the SVG so it survives standalone SVG
395
+ // export and embedding. The fixed IDs are deterministic because an Archify
396
+ // artifact intentionally contains one primary diagram SVG.
397
+ export function svgAccessibleText(meta, kind) {
398
+ const description = meta.subtitle || translateMessage(meta.locale, `diagram.description.${kind}`);
399
+ return ` <title id="archify-diagram-title">${esc(meta.title)}</title>\n <desc id="archify-diagram-description">${esc(description)}</desc>`;
400
+ }
401
+
402
+ export function animateAttr(meta, kind, step) {
403
+ if (meta.animation !== 'trace') return '';
404
+ // Ambient trace must finish inside the fixed six-second WebM capture. The
405
+ // cap affects visual delay only; authored order and semantic identity stay
406
+ // untouched in the JSON, DOM, and relationship contracts.
407
+ const safeStep = Number.isFinite(step) && step >= 0 ? Math.min(12, Math.floor(step)) : 0;
408
+ return ` data-animate="${kind}" style="--step:${safeStep}"`;
409
+ }
410
+
411
+ // Stable semantic hooks for the standalone HTML explorer. IDs already pass
412
+ // the schema's conservative identifier pattern; escape again at the markup
413
+ // boundary so these helpers remain safe if that contract expands later.
414
+ export function focusNodeAttrs(id, label, metadata = {}, locale) {
415
+ const optional = [
416
+ ['data-node-kind', metadata.kind],
417
+ ['data-node-sublabel', metadata.sublabel],
418
+ ['data-node-tag', metadata.tag],
419
+ ['data-node-context', metadata.context],
420
+ ['data-node-brand', metadata.brand],
421
+ ['data-node-brand-id', metadata.brandId],
422
+ ['data-node-brand-status', metadata.brandStatus],
423
+ ['data-node-brand-source', metadata.brandSource],
424
+ ].filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '')
425
+ .map(([name, value]) => ` ${name}="${esc(String(value))}"`)
426
+ .join('');
427
+ const detail = [metadata.sublabel, metadata.context, metadata.brand]
428
+ .filter((value) => value !== undefined && value !== null && String(value).trim() !== '')
429
+ .join(', ');
430
+ const aria = detail
431
+ ? translateMessage(locale, 'node.focus.detail', { label, detail })
432
+ : translateMessage(locale, 'node.focus', { label });
433
+ return `id="node-${esc(id)}" data-node-id="${esc(id)}" data-node-label="${esc(label)}" tabindex="0" role="button" aria-label="${esc(aria)}" aria-pressed="false"${optional}`;
434
+ }
435
+
436
+ // Native SVG titles preserve a compact details-on-demand fallback when the
437
+ // canonical SVG is embedded inline outside the full Archify viewer.
438
+ export function focusNodeTitle(label, metadata = {}) {
439
+ const parts = [label, metadata.sublabel, metadata.context, metadata.tag, metadata.brand]
440
+ .filter((value) => value !== undefined && value !== null && String(value).trim() !== '');
441
+ return `<title>${esc(parts.join(' · '))}</title>`;
442
+ }
443
+
444
+ export function focusEdgeAttrs(from, to, label, key, id) {
445
+ const named = label ? ` data-edge-label="${esc(label)}"` : '';
446
+ const keyed = key !== undefined && key !== null ? ` data-edge-key="${esc(String(key))}"` : '';
447
+ const identified = id !== undefined && id !== null && String(id).trim() !== ''
448
+ ? ` data-edge-id="${esc(String(id))}"`
449
+ : '';
450
+ return `data-edge-from="${esc(from)}" data-edge-to="${esc(to)}"${named}${keyed}${identified}`;
451
+ }
@@ -0,0 +1,132 @@
1
+ export const DESKTOP_READABILITY_VIEWPORT = Object.freeze({ width: 1440, height: 900 });
2
+ export const DESKTOP_READER_MIN_WIDTH = 960;
3
+ export const DESKTOP_READER_HORIZONTAL_CHROME = 30;
4
+ export const DESKTOP_READER_DIAGRAM_WIDTH = DESKTOP_READER_MIN_WIDTH - DESKTOP_READER_HORIZONTAL_CHROME;
5
+ export const MIN_PROJECTED_NODE_TEXT_PX = 6;
6
+ export const DECLARED_WIDE_READER_CONTRACT = 'declared-wide-v1';
7
+ export const DECLARED_WIDE_READER_RATIO = 1.55;
8
+ export const DECLARED_WIDE_READER_MAX_WIDTH = 1920;
9
+ export const DECLARED_WIDE_REFERENCE_BODY_HORIZONTAL_PX = 64;
10
+ export const DECLARED_WIDE_REFERENCE_DIAGRAM_HORIZONTAL_PX = 30;
11
+
12
+ export function projectedNodeTextPx(sourceFontPx, viewBoxWidth, diagramWidth = DESKTOP_READER_DIAGRAM_WIDTH) {
13
+ if (![sourceFontPx, viewBoxWidth, diagramWidth].every(Number.isFinite) || viewBoxWidth <= 0 || diagramWidth <= 0) {
14
+ return Number.NaN;
15
+ }
16
+ return sourceFontPx * Math.min(1, diagramWidth / viewBoxWidth);
17
+ }
18
+
19
+ export function minimumReadableSourceTextPx(
20
+ viewBoxWidth,
21
+ diagramWidth = DESKTOP_READER_DIAGRAM_WIDTH,
22
+ minimumProjectedPx = MIN_PROJECTED_NODE_TEXT_PX,
23
+ ) {
24
+ if (![viewBoxWidth, diagramWidth, minimumProjectedPx].every(Number.isFinite)
25
+ || viewBoxWidth <= 0
26
+ || diagramWidth <= 0
27
+ || minimumProjectedPx <= 0) {
28
+ return Number.NaN;
29
+ }
30
+ return minimumProjectedPx / Math.min(1, diagramWidth / viewBoxWidth);
31
+ }
32
+
33
+ // This is deliberately separate from the legacy 930px projection. Architecture
34
+ // boundary convergence depends on that legacy default, while only a recognized
35
+ // v2 wide Reader may use this declared-width proof.
36
+ export function declaredWideReadabilityBudget({
37
+ viewBoxWidth,
38
+ viewBoxHeight,
39
+ minimumSourceTextPx,
40
+ requestedMinimumTextPx,
41
+ viewportWidth = DESKTOP_READABILITY_VIEWPORT.width,
42
+ bodyHorizontalPx = DECLARED_WIDE_REFERENCE_BODY_HORIZONTAL_PX,
43
+ diagramHorizontalPx = DECLARED_WIDE_REFERENCE_DIAGRAM_HORIZONTAL_PX,
44
+ minimumReaderWidth = DESKTOP_READER_MIN_WIDTH,
45
+ maximumReaderWidth = DECLARED_WIDE_READER_MAX_WIDTH,
46
+ } = {}) {
47
+ const values = [
48
+ viewBoxWidth, viewBoxHeight, minimumSourceTextPx, requestedMinimumTextPx,
49
+ viewportWidth, bodyHorizontalPx, diagramHorizontalPx, minimumReaderWidth, maximumReaderWidth,
50
+ ];
51
+ if (!values.every(Number.isFinite) || viewBoxWidth <= 0 || viewBoxHeight <= 0
52
+ || minimumSourceTextPx <= 0 || requestedMinimumTextPx <= 0 || viewportWidth <= 0
53
+ || bodyHorizontalPx < 0 || diagramHorizontalPx < 0 || minimumReaderWidth <= 0
54
+ || maximumReaderWidth < minimumReaderWidth || viewBoxWidth / viewBoxHeight < DECLARED_WIDE_READER_RATIO) {
55
+ return null;
56
+ }
57
+ const requestedTargetPx = Math.max(MIN_PROJECTED_NODE_TEXT_PX, requestedMinimumTextPx);
58
+ const requestedScale = Math.min(1, requestedTargetPx / minimumSourceTextPx);
59
+ const desiredReaderWidth = Math.max(minimumReaderWidth, viewBoxWidth * requestedScale + diagramHorizontalPx);
60
+ const viewportCap = Math.max(0, viewportWidth - bodyHorizontalPx);
61
+ const cap = Math.min(maximumReaderWidth, viewportCap);
62
+ const actualReaderWidth = Math.min(desiredReaderWidth, cap);
63
+ const guaranteedSvgWidth = Math.max(0, actualReaderWidth - diagramHorizontalPx);
64
+ const projectedMinimumTextPx = projectedNodeTextPx(minimumSourceTextPx, viewBoxWidth, guaranteedSvgWidth);
65
+ const limit = actualReaderWidth < desiredReaderWidth
66
+ ? (viewportCap <= maximumReaderWidth ? 'viewport-cap' : 'reader-cap')
67
+ : 'source-size';
68
+ return {
69
+ requestedTargetPx,
70
+ requestedScale,
71
+ desiredReaderWidth,
72
+ viewportCap,
73
+ maximumReaderWidth,
74
+ actualReaderWidth,
75
+ guaranteedSvgWidth,
76
+ projectedMinimumTextPx,
77
+ hardFloorPx: MIN_PROJECTED_NODE_TEXT_PX,
78
+ hardFloorMet: projectedMinimumTextPx >= MIN_PROJECTED_NODE_TEXT_PX,
79
+ requestedTargetMet: projectedMinimumTextPx >= requestedMinimumTextPx,
80
+ limit,
81
+ };
82
+ }
83
+
84
+ // Vertical chrome that always stacks with the SVG at the 1440x900 desktop
85
+ // viewport, measured from the delivered Viewer with the shortest one-line
86
+ // header and no cards: body padding 12, header 39, diagram padding/border 75.
87
+ // Cards are excluded so the prediction stays a lower bound.
88
+ export const DESKTOP_FIXED_VERTICAL_CHROME_PX = Object.freeze({ body: 12, header: 39, diagram: 75 });
89
+
90
+ // A canvas the Reader can neither narrow (viewBox ratio below the wide
91
+ // threshold) nor scroll readably (no intrinsic-height fit) renders at the full
92
+ // reader width, so its page height is a function of the viewBox alone. Returns
93
+ // null when the Reader has a way to fit the page; otherwise the certain
94
+ // overflow at 1440x900 before any cards are counted.
95
+ export function predictedFixedWidthOverflow({
96
+ viewBoxWidth,
97
+ viewBoxHeight,
98
+ readerFit,
99
+ diagramType,
100
+ viewport = DESKTOP_READABILITY_VIEWPORT,
101
+ bodyHorizontalPx = DECLARED_WIDE_REFERENCE_BODY_HORIZONTAL_PX,
102
+ diagramHorizontalPx = DECLARED_WIDE_REFERENCE_DIAGRAM_HORIZONTAL_PX,
103
+ chrome = DESKTOP_FIXED_VERTICAL_CHROME_PX,
104
+ } = {}) {
105
+ if (![viewBoxWidth, viewBoxHeight].every(Number.isFinite) || viewBoxWidth <= 0 || viewBoxHeight <= 0) return null;
106
+ const ratio = viewBoxWidth / viewBoxHeight;
107
+ if (readerFit === 'intrinsic-height'
108
+ || (readerFit === 'authored-height' && diagramType === 'architecture')
109
+ || ratio >= DECLARED_WIDE_READER_RATIO) return null;
110
+ const svgWidthPx = viewport.width - bodyHorizontalPx - diagramHorizontalPx;
111
+ const svgHeightPx = Math.round(svgWidthPx * viewBoxHeight / viewBoxWidth);
112
+ const fixedChromePx = chrome.body + chrome.header + chrome.diagram;
113
+ const pageHeightPx = svgHeightPx + fixedChromePx;
114
+ if (pageHeightPx <= viewport.height) return null;
115
+ return {
116
+ viewportWidth: viewport.width,
117
+ viewportHeight: viewport.height,
118
+ ratio: Math.round(ratio * 100) / 100,
119
+ wideRatio: DECLARED_WIDE_READER_RATIO,
120
+ svgWidthPx,
121
+ svgHeightPx,
122
+ fixedChromePx,
123
+ pageHeightPx,
124
+ overflowPx: pageHeightPx - viewport.height,
125
+ };
126
+ }
127
+
128
+ export function describeFixedWidthOverflow(issue) {
129
+ const maximumViewBoxHeight = Math.floor(issue.viewBoxWidth / issue.wideRatio);
130
+ const wideViewBoxWidth = Math.ceil(issue.viewBoxHeight * issue.wideRatio);
131
+ return `Preserve every node, relationship, and label. This ${issue.viewBoxWidth}x${issue.viewBoxHeight} canvas (ratio ${issue.ratio}) declares no intrinsic-height fit and is below the ${issue.wideRatio} wide ratio, so the desktop Reader can neither narrow it nor accept vertical scroll: it renders ${issue.svgHeightPx}px tall at the full ${issue.svgWidthPx}px width and the page reaches ${issue.pageHeightPx}px before cards against ${issue.viewportHeight}px, a certain visual-check failure. Either compact vertical spacing so meta.viewBox height is at most ${maximumViewBoxHeight} at this width, or spread content sideways so the width is at least ${wideViewBoxWidth} at this height; for architecture, omitting meta.viewBox lets the renderer size the canvas and declare the fit.`;
132
+ }