@saluzi/saluzi-edu 0.4.7 → 0.4.8

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 (82) hide show
  1. package/dist/cli.js +51 -60
  2. package/dist/guide/guide-data.json +1 -1
  3. package/dist/vendor/archify/LICENSE +22 -0
  4. package/dist/vendor/archify/SKILL.md +137 -0
  5. package/dist/vendor/archify/THIRD_PARTY_NOTICES.md +69 -0
  6. package/dist/vendor/archify/assets/JetBrainsMono-OFL.txt +93 -0
  7. package/dist/vendor/archify/assets/template.html +14934 -0
  8. package/dist/vendor/archify/bin/archify.mjs +2687 -0
  9. package/dist/vendor/archify/bin/open-artifact.mjs +95 -0
  10. package/dist/vendor/archify/bin/preview.mjs +764 -0
  11. package/dist/vendor/archify/bin/visual-check.mjs +1012 -0
  12. package/dist/vendor/archify/brand-marks/README.md +31 -0
  13. package/dist/vendor/archify/brand-marks/catalog.json +131 -0
  14. package/dist/vendor/archify/delta/architecture-delta.mjs +1221 -0
  15. package/dist/vendor/archify/examples/agent-run.lifecycle.json +60 -0
  16. package/dist/vendor/archify/examples/agent-tool-call.workflow.json +94 -0
  17. package/dist/vendor/archify/examples/async-job-roundtrip.sequence.json +61 -0
  18. package/dist/vendor/archify/examples/brand-aware-delivery.architecture.json +47 -0
  19. package/dist/vendor/archify/examples/cache-miss-request.sequence.json +82 -0
  20. package/dist/vendor/archify/examples/checkout-platform.base.architecture.json +31 -0
  21. package/dist/vendor/archify/examples/checkout-platform.head.architecture.json +31 -0
  22. package/dist/vendor/archify/examples/dataflow-product-analytics.html +15045 -0
  23. package/dist/vendor/archify/examples/deployment-release.lifecycle.json +49 -0
  24. package/dist/vendor/archify/examples/event-stream.dataflow.json +57 -0
  25. package/dist/vendor/archify/examples/incident-response.workflow.json +64 -0
  26. package/dist/vendor/archify/examples/lifecycle-agent-run.html +14980 -0
  27. package/dist/vendor/archify/examples/product-analytics.dataflow.json +76 -0
  28. package/dist/vendor/archify/examples/production-deployment.architecture.json +71 -0
  29. package/dist/vendor/archify/examples/release-delivery.workflow.json +62 -0
  30. package/dist/vendor/archify/examples/sequence-cache-miss-request.html +15060 -0
  31. package/dist/vendor/archify/examples/web-app-rendered.html +15009 -0
  32. package/dist/vendor/archify/examples/web-app.architecture.json +46 -0
  33. package/dist/vendor/archify/examples/workflow-agent-tool-call-rendered.html +15051 -0
  34. package/dist/vendor/archify/migrations/workflow-v2.mjs +279 -0
  35. package/dist/vendor/archify/package.json +17 -0
  36. package/dist/vendor/archify/recipes/scenarios.mjs +391 -0
  37. package/dist/vendor/archify/references/authoring-contract.md +243 -0
  38. package/dist/vendor/archify/references/brand-marks.md +65 -0
  39. package/dist/vendor/archify/references/delivery-contract.md +120 -0
  40. package/dist/vendor/archify/references/viewer-runtime.md +45 -0
  41. package/dist/vendor/archify/renderers/architecture/grid.mjs +62 -0
  42. package/dist/vendor/archify/renderers/architecture/render-architecture.mjs +1078 -0
  43. package/dist/vendor/archify/renderers/dataflow/README.md +104 -0
  44. package/dist/vendor/archify/renderers/dataflow/render-dataflow.mjs +483 -0
  45. package/dist/vendor/archify/renderers/lifecycle/README.md +115 -0
  46. package/dist/vendor/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
  47. package/dist/vendor/archify/renderers/sequence/README.md +114 -0
  48. package/dist/vendor/archify/renderers/sequence/render-sequence.mjs +464 -0
  49. package/dist/vendor/archify/renderers/shared/brand-marks.mjs +563 -0
  50. package/dist/vendor/archify/renderers/shared/cli.mjs +218 -0
  51. package/dist/vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
  52. package/dist/vendor/archify/renderers/shared/diagnostics.mjs +127 -0
  53. package/dist/vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
  54. package/dist/vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  55. package/dist/vendor/archify/renderers/shared/generated-validators.mjs +13 -0
  56. package/dist/vendor/archify/renderers/shared/geometry.mjs +1423 -0
  57. package/dist/vendor/archify/renderers/shared/i18n.mjs +595 -0
  58. package/dist/vendor/archify/renderers/shared/layout-report.mjs +40 -0
  59. package/dist/vendor/archify/renderers/shared/legend.mjs +217 -0
  60. package/dist/vendor/archify/renderers/shared/output-path.mjs +340 -0
  61. package/dist/vendor/archify/renderers/shared/repository-evidence.mjs +238 -0
  62. package/dist/vendor/archify/renderers/shared/repository-location.mjs +58 -0
  63. package/dist/vendor/archify/renderers/shared/text-fit.mjs +49 -0
  64. package/dist/vendor/archify/renderers/shared/utils.mjs +232 -0
  65. package/dist/vendor/archify/renderers/shared/validator.mjs +86 -0
  66. package/dist/vendor/archify/renderers/workflow/README.md +223 -0
  67. package/dist/vendor/archify/renderers/workflow/render-workflow.mjs +35 -0
  68. package/dist/vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
  69. package/dist/vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  70. package/dist/vendor/archify/schemas/README.md +211 -0
  71. package/dist/vendor/archify/schemas/architecture.schema.json +178 -0
  72. package/dist/vendor/archify/schemas/common.schema.json +115 -0
  73. package/dist/vendor/archify/schemas/dataflow.schema.json +243 -0
  74. package/dist/vendor/archify/schemas/lifecycle.schema.json +266 -0
  75. package/dist/vendor/archify/schemas/sequence.schema.json +223 -0
  76. package/dist/vendor/archify/schemas/workflow.schema.json +428 -0
  77. package/dist/vendor/archify/scripts/check-render-output.mjs +836 -0
  78. package/dist/vendor/archify/scripts/check-update.mjs +1667 -0
  79. package/dist/vendor/archify/scripts/render-examples.mjs +26 -0
  80. package/dist/vendor/archify/scripts/update-contract.mjs +182 -0
  81. package/dist/vendor/archify/skill-release.json +10 -0
  82. package/package.json +1 -1
@@ -0,0 +1,2687 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from 'node:child_process'
4
+ import { createHash } from 'node:crypto'
5
+ import fs from 'node:fs'
6
+ import os from 'node:os'
7
+ import path from 'node:path'
8
+ import { fileURLToPath, pathToFileURL } from 'node:url'
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
11
+ const skillRoot = path.resolve(__dirname, '..')
12
+
13
+ const TYPES = new Set([
14
+ 'architecture',
15
+ 'workflow',
16
+ 'sequence',
17
+ 'dataflow',
18
+ 'lifecycle',
19
+ ])
20
+
21
+ function usage() {
22
+ return `Usage:
23
+ archify render <type> <input.json> [output.html] [--quality standard|showcase] [--repo-root path (architecture only)]
24
+ archify compare architecture <base.json> <head.json> [output.html] [--receipt path] [--json] [--quality standard|showcase] [--repo-root path]
25
+ archify deliver <type> <input.json> [output.html] [--json] [--open] [--quality standard|showcase] [--repo-root path (architecture only)]
26
+ archify preview <type> <input.json> [output.html] [--no-open] [--quality standard|showcase] [--repo-root path (architecture only)]
27
+ archify validate <type> <input.json> [--json] [--layout-json] [--quality standard|showcase] [--repo-root path (architecture only)]
28
+ archify migrate workflow <old.json> <new.json> --to-schema 2 [--json]
29
+ archify inspect <type> <input.json>
30
+ archify check <output.html>
31
+ archify visual-check <output.html> [--json]
32
+ archify guide [scenario or question] [--json] [--lang en|zh]
33
+ archify brands [name, alias, domain, or category] [--json]
34
+ archify brands capture <url> [--json]
35
+ archify examples
36
+ archify doctor
37
+ archify demo [output-directory]
38
+
39
+ Types:
40
+ architecture, workflow, sequence, dataflow, lifecycle
41
+ `
42
+ }
43
+
44
+ function fail(message, code = 2) {
45
+ console.error(message)
46
+ process.exit(code)
47
+ }
48
+
49
+ function rejectCliArgument(message, details = {}) {
50
+ const error = new Error(message)
51
+ error.archifyArgument = {
52
+ code: details.code || 'cli/invalid-arguments',
53
+ subject: details.subject || {},
54
+ evidence: details.evidence || {},
55
+ supportedFixes: details.supportedFixes || [
56
+ 'correct the command arguments and retry',
57
+ ],
58
+ }
59
+ throw error
60
+ }
61
+
62
+ function rendererPath(type) {
63
+ if (!TYPES.has(type)) {
64
+ rejectCliArgument(
65
+ `Unknown diagram type "${type}". Expected one of: ${[...TYPES].join(', ')}`,
66
+ {
67
+ code: 'cli/unknown-diagram-type',
68
+ subject: { type },
69
+ evidence: { supportedTypes: [...TYPES] },
70
+ supportedFixes: [`use one of: ${[...TYPES].join(', ')}`],
71
+ },
72
+ )
73
+ }
74
+ return path.join(skillRoot, 'renderers', type, `render-${type}.mjs`)
75
+ }
76
+
77
+ function runNode(args, options = {}) {
78
+ return spawnSync(process.execPath, args, {
79
+ cwd: options.cwd || process.cwd(),
80
+ encoding: 'utf8',
81
+ stdio: options.stdio || 'inherit',
82
+ env: options.env ? { ...process.env, ...options.env } : process.env,
83
+ })
84
+ }
85
+
86
+ function extractQualityArgs(args) {
87
+ const rest = []
88
+ let quality
89
+ for (let index = 0; index < args.length; index += 1) {
90
+ const arg = args[index]
91
+ if (arg === '--quality') {
92
+ quality = args[index + 1]
93
+ if (!quality || quality.startsWith('--'))
94
+ rejectCliArgument('--quality requires standard or showcase.', {
95
+ code: 'cli/missing-option-value',
96
+ subject: { option: '--quality' },
97
+ supportedFixes: ['provide --quality standard or --quality showcase'],
98
+ })
99
+ index += 1
100
+ continue
101
+ }
102
+ if (arg.startsWith('--quality=')) {
103
+ quality = arg.slice('--quality='.length)
104
+ if (!quality)
105
+ rejectCliArgument('--quality requires standard or showcase.', {
106
+ code: 'cli/missing-option-value',
107
+ subject: { option: '--quality' },
108
+ supportedFixes: ['provide --quality standard or --quality showcase'],
109
+ })
110
+ continue
111
+ }
112
+ rest.push(arg)
113
+ }
114
+ if (quality !== undefined && !['standard', 'showcase'].includes(quality)) {
115
+ rejectCliArgument(
116
+ `Unknown quality profile "${quality}". Expected standard or showcase.`,
117
+ {
118
+ code: 'cli/invalid-option-value',
119
+ subject: { option: '--quality' },
120
+ evidence: { value: quality, supportedValues: ['standard', 'showcase'] },
121
+ supportedFixes: ['use --quality standard or --quality showcase'],
122
+ },
123
+ )
124
+ }
125
+ return { rest, quality }
126
+ }
127
+
128
+ function extractRepoRootArgs(args) {
129
+ const rest = []
130
+ let repoRoot
131
+ for (let index = 0; index < args.length; index += 1) {
132
+ const arg = args[index]
133
+ if (arg === '--repo-root') {
134
+ repoRoot = args[index + 1]
135
+ if (!repoRoot || repoRoot.startsWith('--'))
136
+ rejectCliArgument('--repo-root requires a repository path.', {
137
+ code: 'cli/missing-option-value',
138
+ subject: { option: '--repo-root' },
139
+ supportedFixes: ['provide one repository path after --repo-root'],
140
+ })
141
+ index += 1
142
+ continue
143
+ }
144
+ if (arg.startsWith('--repo-root=')) {
145
+ repoRoot = arg.slice('--repo-root='.length)
146
+ if (!repoRoot)
147
+ rejectCliArgument('--repo-root requires a repository path.', {
148
+ code: 'cli/missing-option-value',
149
+ subject: { option: '--repo-root' },
150
+ supportedFixes: ['provide one repository path after --repo-root'],
151
+ })
152
+ continue
153
+ }
154
+ rest.push(arg)
155
+ }
156
+ return { rest, repoRoot: repoRoot ? path.resolve(repoRoot) : undefined }
157
+ }
158
+
159
+ function rendererEnv(quality, repoRoot, diagnosticJson = false) {
160
+ return {
161
+ ...(quality ? { ARCHIFY_QUALITY_PROFILE: quality } : {}),
162
+ ...(repoRoot ? { ARCHIFY_REPO_ROOT: repoRoot } : {}),
163
+ ...(diagnosticJson ? { ARCHIFY_DIAGNOSTIC_FORMAT: 'json' } : {}),
164
+ }
165
+ }
166
+
167
+ function diagnostic({
168
+ code,
169
+ message,
170
+ subject = {},
171
+ evidence = {},
172
+ supportedFixes = [],
173
+ severity = 'error',
174
+ }) {
175
+ return {
176
+ code,
177
+ severity,
178
+ message,
179
+ subject,
180
+ evidence,
181
+ supportedFixes,
182
+ }
183
+ }
184
+
185
+ function inputDiagnostic(error, inputPath) {
186
+ const isSyntax = error instanceof SyntaxError
187
+ return diagnostic({
188
+ code: isSyntax ? 'input/json-parse' : 'input/read',
189
+ message: isSyntax
190
+ ? `Input JSON could not be parsed: ${error.message}`
191
+ : `Input could not be read: ${error.message}`,
192
+ subject: { input: inputPath },
193
+ evidence: {
194
+ ...(error?.code ? { systemCode: error.code } : {}),
195
+ reason: error.message,
196
+ },
197
+ supportedFixes: [
198
+ isSyntax
199
+ ? 'repair the JSON syntax and run validation again'
200
+ : 'provide one readable JSON input file',
201
+ ],
202
+ })
203
+ }
204
+
205
+ function rendererFailure(result) {
206
+ if (result.error) {
207
+ return {
208
+ error: 'Renderer process could not start.',
209
+ diagnostics: [
210
+ diagnostic({
211
+ code: 'internal/renderer-process',
212
+ message: 'Renderer process could not start.',
213
+ evidence: { reason: result.error.message },
214
+ }),
215
+ ],
216
+ }
217
+ }
218
+ try {
219
+ const payload = JSON.parse((result.stderr || '').trim())
220
+ if (
221
+ payload?.ok === false &&
222
+ Array.isArray(payload.diagnostics) &&
223
+ payload.diagnostics.length
224
+ ) {
225
+ return {
226
+ error: payload.error || payload.diagnostics[0].message,
227
+ diagnostics: payload.diagnostics,
228
+ }
229
+ }
230
+ } catch {
231
+ // The diagnostic boundary is intentionally fail-closed. Never copy a raw
232
+ // Node stack into a machine receipt when a renderer exits unexpectedly.
233
+ }
234
+ return {
235
+ error: 'Renderer failed before emitting a structured diagnostic.',
236
+ diagnostics: [
237
+ diagnostic({
238
+ code: 'internal/unclassified',
239
+ message: 'Renderer failed before emitting a structured diagnostic.',
240
+ evidence: { exitCode: result.status ?? 1 },
241
+ }),
242
+ ],
243
+ }
244
+ }
245
+
246
+ const COMPOSITION_CHECKS = new Set([
247
+ 'label_route_clearance',
248
+ 'relationship_crossings',
249
+ 'relationship_corridors',
250
+ 'container_border_runs',
251
+ 'route_rhythm',
252
+ ])
253
+
254
+ const CHECK_FIXES = {
255
+ single_svg: [
256
+ 'remove additional SVG roots so the artifact contains exactly one diagram SVG',
257
+ ],
258
+ finite_svg: ['replace non-finite coordinates before rendering again'],
259
+ orthogonal_arrows: ['use renderer-supported orthogonal routing controls'],
260
+ legend_clearance: [
261
+ 'move the route or enlarge the viewBox so relationships do not enter the legend',
262
+ ],
263
+ }
264
+
265
+ const COMPOSITION_FIXES = {
266
+ 'composition/proper-crossing': [
267
+ 'adjust route/via or channel coordinates so unrelated relationships use separate corridors',
268
+ ],
269
+ 'composition/ambiguous-corridor': [
270
+ 'adjust route/via or channel coordinates so unrelated relationships do not visually merge',
271
+ ],
272
+ 'composition/container-border-run': [
273
+ 'route across the frame perpendicularly through a clear opening',
274
+ ],
275
+ 'composition/label-route-clearance': [
276
+ 'adjust labelAt, labelDx, labelDy, labelSegment, message y, or the other relationship route',
277
+ ],
278
+ 'composition/desktop-readability': [
279
+ 'reduce the viewBox width, shorten node copy, widen affected nodes, or split the diagram so node context remains at least 6px at a 1440px desktop viewport',
280
+ ],
281
+ 'composition/micro-segment': [
282
+ 'move the route/channel/via point so every visible segment is at least 8px',
283
+ ],
284
+ 'composition/short-interior-segment': [
285
+ 'move the route/channel/via point so every interior turn has at least 16px',
286
+ ],
287
+ }
288
+
289
+ function checkerDiagnostics(checker) {
290
+ const diagnostics = []
291
+ for (const issue of checker?.composition?.issues || []) {
292
+ if (issue.severity !== 'error') continue
293
+ const { severity, code, relationship, ...evidence } = issue
294
+ diagnostics.push(
295
+ diagnostic({
296
+ code,
297
+ severity,
298
+ message: `Final artifact failed ${code}.`,
299
+ subject: relationship ? { relationship } : { check: 'composition' },
300
+ evidence,
301
+ supportedFixes: COMPOSITION_FIXES[code] || [],
302
+ }),
303
+ )
304
+ }
305
+ for (const check of checker?.checks || []) {
306
+ if (check.ok || COMPOSITION_CHECKS.has(check.name)) continue
307
+ diagnostics.push(
308
+ diagnostic({
309
+ code: `artifact/${check.name.replaceAll('_', '-')}`,
310
+ message:
311
+ (check.details || []).find(Boolean) ||
312
+ `Final artifact failed ${check.name}.`,
313
+ subject: { check: check.name },
314
+ evidence: { details: check.details || [] },
315
+ supportedFixes: CHECK_FIXES[check.name] || [],
316
+ }),
317
+ )
318
+ }
319
+ return diagnostics.length
320
+ ? diagnostics
321
+ : [
322
+ diagnostic({
323
+ code: 'artifact/check-failed',
324
+ message:
325
+ 'Final artifact check failed without a classified diagnostic.',
326
+ subject: { check: 'unknown' },
327
+ evidence: {},
328
+ }),
329
+ ]
330
+ }
331
+
332
+ function formatDiagnostics(error, diagnostics = []) {
333
+ if (!diagnostics.length) return error
334
+ return [
335
+ error,
336
+ ...diagnostics.map(entry => {
337
+ const fix = entry.supportedFixes?.length
338
+ ? ` Fix: ${entry.supportedFixes.join('; ')}.`
339
+ : ''
340
+ return `[${entry.code}] ${entry.message}${fix}`
341
+ }),
342
+ ].join('\n')
343
+ }
344
+
345
+ function assertEvidenceType(type, repoRoot) {
346
+ if (repoRoot && type !== 'architecture') {
347
+ rejectCliArgument(
348
+ '--repo-root is currently supported for architecture diagrams only.',
349
+ {
350
+ code: 'cli/unsupported-option',
351
+ subject: { option: '--repo-root', type },
352
+ supportedFixes: ['remove --repo-root or use an architecture diagram'],
353
+ },
354
+ )
355
+ }
356
+ }
357
+
358
+ function exitFrom(result) {
359
+ if (result.error) fail(result.error.message, 1)
360
+ process.exit(result.status ?? 1)
361
+ }
362
+
363
+ function reportCompareFailure({
364
+ json,
365
+ stage,
366
+ error,
367
+ code = 'delta/internal',
368
+ details = {},
369
+ status = 1,
370
+ }) {
371
+ const receipt = {
372
+ schemaVersion: 1,
373
+ ok: false,
374
+ command: 'compare',
375
+ type: 'architecture',
376
+ stage,
377
+ error,
378
+ diagnostics: [
379
+ {
380
+ code,
381
+ severity: 'error',
382
+ message: error,
383
+ subject: details.side
384
+ ? {
385
+ side: details.side,
386
+ ...(details.path ? { path: details.path } : {}),
387
+ }
388
+ : {},
389
+ evidence: Object.fromEntries(
390
+ Object.entries(details).filter(
391
+ ([key]) => !['side', 'path', 'supportedFixes'].includes(key),
392
+ ),
393
+ ),
394
+ supportedFixes: details.supportedFixes || [],
395
+ },
396
+ ],
397
+ }
398
+ if (json) console.log(JSON.stringify(receipt, null, 2))
399
+ else console.error(formatDiagnostics(error, receipt.diagnostics))
400
+ process.exitCode = status
401
+ }
402
+
403
+ function extractCompareOptions(args) {
404
+ const positional = []
405
+ let receipt
406
+ let json = false
407
+ for (let index = 0; index < args.length; index += 1) {
408
+ const arg = args[index]
409
+ if (arg === '--json') {
410
+ json = true
411
+ continue
412
+ }
413
+ if (arg === '--receipt') {
414
+ receipt = args[index + 1]
415
+ if (!receipt || receipt.startsWith('--'))
416
+ fail('--receipt requires a JSON output path.')
417
+ index += 1
418
+ continue
419
+ }
420
+ if (arg.startsWith('--receipt=')) {
421
+ receipt = arg.slice('--receipt='.length)
422
+ if (!receipt) fail('--receipt requires a JSON output path.')
423
+ continue
424
+ }
425
+ if (arg.startsWith('--')) fail(`Unknown compare option "${arg}".`)
426
+ positional.push(arg)
427
+ }
428
+ return { positional, receipt, json }
429
+ }
430
+
431
+ function compareReceiptPath(outputPath) {
432
+ const extension = path.extname(outputPath)
433
+ return extension
434
+ ? `${outputPath.slice(0, -extension.length)}.receipt.json`
435
+ : `${outputPath}.receipt.json`
436
+ }
437
+
438
+ function compareCommitError(message, code, details = {}) {
439
+ const error = new Error(message)
440
+ error.compareStage = 'commit'
441
+ error.compareCode = code
442
+ error.compareDetails = details
443
+ return error
444
+ }
445
+
446
+ function commitComparePair({
447
+ htmlCandidate,
448
+ receiptCandidate,
449
+ outputPath,
450
+ receiptPath,
451
+ stagingDirectory,
452
+ }) {
453
+ const targets = [
454
+ {
455
+ label: 'HTML artifact',
456
+ target: outputPath,
457
+ candidate: htmlCandidate,
458
+ backup: path.join(stagingDirectory, '.previous-output'),
459
+ },
460
+ {
461
+ label: 'receipt',
462
+ target: receiptPath,
463
+ candidate: receiptCandidate,
464
+ backup: path.join(stagingDirectory, '.previous-receipt'),
465
+ },
466
+ ]
467
+
468
+ // Preflight the whole pair before moving either trusted target. This avoids
469
+ // replacing the HTML and only then discovering that its receipt destination
470
+ // cannot be committed (for example, because it is a directory).
471
+ for (const item of targets) {
472
+ if (!fs.existsSync(item.target)) continue
473
+ const existing = fs.lstatSync(item.target)
474
+ if (!existing.isFile()) {
475
+ throw compareCommitError(
476
+ `Could not commit Architecture Delta: existing ${item.label} target is not a regular file.`,
477
+ 'delta/commit-target',
478
+ {
479
+ target: path.basename(item.target),
480
+ targetType: existing.isDirectory() ? 'directory' : 'non-file',
481
+ supportedFixes: [`choose a regular-file path for the ${item.label}`],
482
+ },
483
+ )
484
+ }
485
+ }
486
+
487
+ const backedUp = []
488
+ const committed = []
489
+ try {
490
+ for (const item of targets) {
491
+ if (!fs.existsSync(item.target)) continue
492
+ fs.renameSync(item.target, item.backup)
493
+ backedUp.push(item)
494
+ }
495
+ for (const item of targets) {
496
+ fs.renameSync(item.candidate, item.target)
497
+ committed.push(item)
498
+ }
499
+ } catch (cause) {
500
+ const rollbackErrors = []
501
+ for (const item of [...committed].reverse()) {
502
+ try {
503
+ fs.rmSync(item.target, { force: true })
504
+ } catch (error) {
505
+ rollbackErrors.push(`${item.label}: remove failed (${error.message})`)
506
+ }
507
+ }
508
+ for (const item of [...backedUp].reverse()) {
509
+ try {
510
+ if (fs.existsSync(item.target)) fs.rmSync(item.target, { force: true })
511
+ fs.renameSync(item.backup, item.target)
512
+ } catch (error) {
513
+ rollbackErrors.push(`${item.label}: restore failed (${error.message})`)
514
+ }
515
+ }
516
+ throw compareCommitError(
517
+ rollbackErrors.length
518
+ ? 'Architecture Delta pair commit failed and its previous files could not be fully restored.'
519
+ : 'Architecture Delta pair commit failed; the previous files were restored.',
520
+ rollbackErrors.length
521
+ ? 'delta/commit-rollback-failed'
522
+ : 'delta/commit-failed',
523
+ {
524
+ reason: cause.message,
525
+ ...(rollbackErrors.length ? { rollbackErrors } : {}),
526
+ supportedFixes: [
527
+ 'check that both output paths are writable regular files, then retry',
528
+ ],
529
+ },
530
+ )
531
+ }
532
+ }
533
+
534
+ function renderValidatedArchitecture(inputPath, outputPath, quality, repoRoot) {
535
+ const render = runNode(
536
+ [rendererPath('architecture'), inputPath, outputPath],
537
+ {
538
+ stdio: 'pipe',
539
+ env: rendererEnv(quality, repoRoot, true),
540
+ },
541
+ )
542
+ if (render.status !== 0) {
543
+ const failure = rendererFailure(render)
544
+ const error = new Error(failure.error)
545
+ error.compareStage = 'input'
546
+ error.compareStatus = render.status ?? 1
547
+ error.diagnostics = failure.diagnostics
548
+ throw error
549
+ }
550
+ const check = runNode(
551
+ [path.join(skillRoot, 'scripts/check-render-output.mjs'), outputPath],
552
+ { stdio: 'pipe' },
553
+ )
554
+ if (check.status !== 0) {
555
+ const error = new Error('Validated snapshot failed final artifact checks.')
556
+ error.compareStage = 'check'
557
+ error.compareStatus = check.status ?? 1
558
+ try {
559
+ error.checker = JSON.parse(check.stdout)
560
+ error.diagnostics = checkerDiagnostics(error.checker)
561
+ } catch {
562
+ error.diagnostics = []
563
+ }
564
+ throw error
565
+ }
566
+ const artifact = fs.readFileSync(outputPath)
567
+ return {
568
+ artifact,
569
+ html: artifact.toString('utf8'),
570
+ checks: JSON.parse(check.stdout),
571
+ sourceEvidence: sourceEvidenceFromArtifact(artifact),
572
+ }
573
+ }
574
+
575
+ async function commandCompare(args) {
576
+ const { resolveOutputPath } = await import(
577
+ '../renderers/shared/output-path.mjs'
578
+ )
579
+ const qualityArgs = extractQualityArgs(args)
580
+ const repoArgs = extractRepoRootArgs(qualityArgs.rest)
581
+ const options = extractCompareOptions(repoArgs.rest)
582
+ const [type, baseInput, headInput, requestedOutput] = options.positional
583
+ if (
584
+ type !== 'architecture' ||
585
+ !baseInput ||
586
+ !headInput ||
587
+ options.positional.length > 4
588
+ )
589
+ fail(usage())
590
+ let deltaRuntime
591
+ try {
592
+ deltaRuntime = await import(
593
+ pathToFileURL(path.join(skillRoot, 'delta/architecture-delta.mjs')).href
594
+ )
595
+ } catch (error) {
596
+ reportCompareFailure({
597
+ json: options.json,
598
+ stage: 'prepare',
599
+ error: 'Architecture compare runtime is unavailable.',
600
+ code: 'delta/runtime-missing',
601
+ details: {
602
+ reason: error.message,
603
+ supportedFixes: ['install the complete Archify skill package'],
604
+ },
605
+ })
606
+ return
607
+ }
608
+ const {
609
+ ArchitectureDeltaError,
610
+ annotateArchitectureSideSvg,
611
+ buildDeltaSvg,
612
+ canonicalArchitecture,
613
+ canonicalArchitectureJson,
614
+ compareArchitecture,
615
+ extractArchitectureSvg,
616
+ extractArtifactCss,
617
+ renderArchitectureDeltaHtml,
618
+ validateArchitectureDeltaHtml,
619
+ } = deltaRuntime
620
+
621
+ const basePath = path.resolve(baseInput)
622
+ const headPath = path.resolve(headInput)
623
+ const receiptTarget =
624
+ options.receipt ||
625
+ compareReceiptPath(
626
+ path.resolve(requestedOutput || 'architecture-delta.html'),
627
+ )
628
+ let outputPath
629
+ try {
630
+ ;({ outputPath } = resolveOutputPath({
631
+ requestedOutput,
632
+ defaultOutput: 'architecture-delta.html',
633
+ inputPaths: [basePath, headPath],
634
+ otherOutputPaths: [path.resolve(receiptTarget)],
635
+ }))
636
+ } catch (error) {
637
+ const outputDiagnostic = error.archifyDiagnostics?.[0]
638
+ reportCompareFailure({
639
+ json: options.json,
640
+ stage: 'prepare',
641
+ error: error.message,
642
+ code: outputDiagnostic?.code || 'output/path-resolution',
643
+ details: {
644
+ ...(outputDiagnostic?.subject || {}),
645
+ ...(outputDiagnostic?.evidence || {}),
646
+ supportedFixes: outputDiagnostic?.supportedFixes || [
647
+ 'choose a safe output path and retry',
648
+ ],
649
+ },
650
+ })
651
+ return
652
+ }
653
+ let receiptPath
654
+ try {
655
+ ;({ outputPath: receiptPath } = resolveOutputPath({
656
+ requestedOutput: options.receipt || compareReceiptPath(outputPath),
657
+ defaultOutput: compareReceiptPath(outputPath),
658
+ requiredExtension: '.json',
659
+ inputPaths: [basePath, headPath],
660
+ otherOutputPaths: [outputPath],
661
+ }))
662
+ } catch (error) {
663
+ const outputDiagnostic = error.archifyDiagnostics?.[0]
664
+ reportCompareFailure({
665
+ json: options.json,
666
+ stage: 'prepare',
667
+ error: error.message,
668
+ code: outputDiagnostic?.code || 'output/path-resolution',
669
+ details: {
670
+ ...(outputDiagnostic?.subject || {}),
671
+ ...(outputDiagnostic?.evidence || {}),
672
+ supportedFixes: outputDiagnostic?.supportedFixes || [
673
+ 'choose a safe receipt path and retry',
674
+ ],
675
+ },
676
+ })
677
+ return
678
+ }
679
+ let baseBuffer
680
+ let headBuffer
681
+ let base
682
+ let head
683
+ try {
684
+ baseBuffer = fs.readFileSync(basePath)
685
+ base = JSON.parse(baseBuffer.toString('utf8'))
686
+ } catch (error) {
687
+ reportCompareFailure({
688
+ json: options.json,
689
+ stage: 'input',
690
+ error: `Could not read base input: ${error.message}`,
691
+ code: 'delta/base-input',
692
+ details: { side: 'base', reason: error.message },
693
+ })
694
+ return
695
+ }
696
+ try {
697
+ headBuffer = fs.readFileSync(headPath)
698
+ head = JSON.parse(headBuffer.toString('utf8'))
699
+ } catch (error) {
700
+ reportCompareFailure({
701
+ json: options.json,
702
+ stage: 'input',
703
+ error: `Could not read head input: ${error.message}`,
704
+ code: 'delta/head-input',
705
+ details: { side: 'head', reason: error.message },
706
+ })
707
+ return
708
+ }
709
+
710
+ const outputDirectory = path.dirname(outputPath)
711
+ if (path.dirname(receiptPath) !== outputDirectory) {
712
+ reportCompareFailure({
713
+ json: options.json,
714
+ stage: 'prepare',
715
+ error: 'The compare receipt must be written beside the HTML artifact.',
716
+ code: 'delta/receipt-directory',
717
+ details: {
718
+ supportedFixes: [
719
+ 'choose a --receipt path in the same directory as output.html',
720
+ ],
721
+ },
722
+ })
723
+ return
724
+ }
725
+ try {
726
+ fs.mkdirSync(outputDirectory, { recursive: true })
727
+ } catch (error) {
728
+ reportCompareFailure({
729
+ json: options.json,
730
+ stage: 'prepare',
731
+ error: `Could not create compare output directory: ${error.message}`,
732
+ code: 'delta/output-directory',
733
+ details: { reason: error.message },
734
+ })
735
+ return
736
+ }
737
+
738
+ let stagingDirectory
739
+ try {
740
+ stagingDirectory = fs.mkdtempSync(
741
+ path.join(outputDirectory, '.archify-compare-'),
742
+ )
743
+ } catch (error) {
744
+ reportCompareFailure({
745
+ json: options.json,
746
+ stage: 'prepare',
747
+ error: `Could not create compare candidate: ${error.message}`,
748
+ code: 'delta/candidate-directory',
749
+ details: { reason: error.message },
750
+ })
751
+ return
752
+ }
753
+
754
+ const baseCandidate = path.join(stagingDirectory, 'base.html')
755
+ const headCandidate = path.join(stagingDirectory, 'head.html')
756
+ const rawBaseCandidate = path.join(stagingDirectory, 'base.raw.html')
757
+ const rawHeadCandidate = path.join(stagingDirectory, 'head.raw.html')
758
+ const canonicalBaseInput = path.join(
759
+ stagingDirectory,
760
+ 'base.architecture.json',
761
+ )
762
+ const canonicalHeadInput = path.join(
763
+ stagingDirectory,
764
+ 'head.architecture.json',
765
+ )
766
+ const htmlCandidate = path.join(stagingDirectory, path.basename(outputPath))
767
+ const receiptCandidate = path.join(
768
+ stagingDirectory,
769
+ path.basename(receiptPath),
770
+ )
771
+
772
+ try {
773
+ let baseResult
774
+ let headResult
775
+ try {
776
+ renderValidatedArchitecture(
777
+ basePath,
778
+ rawBaseCandidate,
779
+ qualityArgs.quality,
780
+ repoArgs.repoRoot,
781
+ )
782
+ } catch (error) {
783
+ const diagnosticEntry = error.diagnostics?.[0]
784
+ reportCompareFailure({
785
+ json: options.json,
786
+ stage: error.compareStage || 'validate',
787
+ error: `Base snapshot failed validation: ${error.message}`,
788
+ code: diagnosticEntry?.code || 'delta/base-validation',
789
+ details: {
790
+ side: 'base',
791
+ ...(diagnosticEntry?.subject?.path
792
+ ? { path: diagnosticEntry.subject.path }
793
+ : {}),
794
+ ...(diagnosticEntry?.evidence || {}),
795
+ supportedFixes: diagnosticEntry?.supportedFixes || [],
796
+ },
797
+ status: error.compareStatus || 1,
798
+ })
799
+ return
800
+ }
801
+ try {
802
+ renderValidatedArchitecture(
803
+ headPath,
804
+ rawHeadCandidate,
805
+ qualityArgs.quality,
806
+ repoArgs.repoRoot,
807
+ )
808
+ } catch (error) {
809
+ const diagnosticEntry = error.diagnostics?.[0]
810
+ reportCompareFailure({
811
+ json: options.json,
812
+ stage: error.compareStage || 'validate',
813
+ error: `Head snapshot failed validation: ${error.message}`,
814
+ code: diagnosticEntry?.code || 'delta/head-validation',
815
+ details: {
816
+ side: 'head',
817
+ ...(diagnosticEntry?.subject?.path
818
+ ? { path: diagnosticEntry.subject.path }
819
+ : {}),
820
+ ...(diagnosticEntry?.evidence || {}),
821
+ supportedFixes: diagnosticEntry?.supportedFixes || [],
822
+ },
823
+ status: error.compareStatus || 1,
824
+ })
825
+ return
826
+ }
827
+
828
+ // Validation must see the exact authored inputs. Only after both sides
829
+ // pass do we canonicalize their collection order for deterministic SVG
830
+ // geometry and stable artifact bytes.
831
+ fs.writeFileSync(
832
+ canonicalBaseInput,
833
+ JSON.stringify(canonicalArchitecture(base)),
834
+ )
835
+ fs.writeFileSync(
836
+ canonicalHeadInput,
837
+ JSON.stringify(canonicalArchitecture(head)),
838
+ )
839
+ baseResult = renderValidatedArchitecture(
840
+ canonicalBaseInput,
841
+ baseCandidate,
842
+ qualityArgs.quality,
843
+ repoArgs.repoRoot,
844
+ )
845
+ headResult = renderValidatedArchitecture(
846
+ canonicalHeadInput,
847
+ headCandidate,
848
+ qualityArgs.quality,
849
+ repoArgs.repoRoot,
850
+ )
851
+
852
+ const semanticHash = diagram =>
853
+ createHash('sha256')
854
+ .update(canonicalArchitectureJson(diagram))
855
+ .digest('hex')
856
+ let compareIr
857
+ try {
858
+ compareIr = compareArchitecture(base, head, {
859
+ baseRawSha256: createHash('sha256').update(baseBuffer).digest('hex'),
860
+ headRawSha256: createHash('sha256').update(headBuffer).digest('hex'),
861
+ baseSemanticSha256: semanticHash(base),
862
+ headSemanticSha256: semanticHash(head),
863
+ baseBytes: baseBuffer.byteLength,
864
+ headBytes: headBuffer.byteLength,
865
+ baseVerified: Boolean(baseResult.sourceEvidence),
866
+ headVerified: Boolean(headResult.sourceEvidence),
867
+ })
868
+ } catch (error) {
869
+ if (!(error instanceof ArchitectureDeltaError)) throw error
870
+ reportCompareFailure({
871
+ json: options.json,
872
+ stage: 'compare',
873
+ error: error.message,
874
+ code: error.code,
875
+ details: error.details,
876
+ })
877
+ return
878
+ }
879
+
880
+ const baseSourceSvg = extractArchitectureSvg(baseResult.html)
881
+ const headSourceSvg = extractArchitectureSvg(headResult.html)
882
+ const baseSvg = annotateArchitectureSideSvg(
883
+ baseSourceSvg,
884
+ compareIr,
885
+ 'base',
886
+ )
887
+ const headSvg = annotateArchitectureSideSvg(
888
+ headSourceSvg,
889
+ compareIr,
890
+ 'head',
891
+ )
892
+ const deltaSvg = buildDeltaSvg(baseSourceSvg, headSourceSvg, compareIr)
893
+ // Raw input hashes and byte counts belong in the sidecar receipt, not the
894
+ // artifact. Keeping them out makes formatting-only input rewrites produce
895
+ // the exact same canonical review HTML and artifact hash.
896
+ const artifactIr = {
897
+ ...compareIr,
898
+ base: Object.fromEntries(
899
+ Object.entries(compareIr.base).filter(
900
+ ([key]) => !['rawSha256', 'bytes'].includes(key),
901
+ ),
902
+ ),
903
+ head: Object.fromEntries(
904
+ Object.entries(compareIr.head).filter(
905
+ ([key]) => !['rawSha256', 'bytes'].includes(key),
906
+ ),
907
+ ),
908
+ }
909
+ const html = renderArchitectureDeltaHtml({
910
+ receipt: artifactIr,
911
+ baseSvg,
912
+ deltaSvg,
913
+ headSvg,
914
+ baseHtml: baseResult.html,
915
+ headHtml: headResult.html,
916
+ artifactCss: extractArtifactCss(headResult.html),
917
+ })
918
+ const deltaValidation = validateArchitectureDeltaHtml(html, artifactIr)
919
+ fs.writeFileSync(htmlCandidate, html)
920
+ const artifact = fs.readFileSync(htmlCandidate)
921
+ const baseChecks = baseResult.checks.checks.filter(check => check.ok).length
922
+ const headChecks = headResult.checks.checks.filter(check => check.ok).length
923
+ const finalReceipt = {
924
+ ...compareIr,
925
+ artifact: {
926
+ sha256: createHash('sha256').update(artifact).digest('hex'),
927
+ bytes: artifact.byteLength,
928
+ },
929
+ validation: {
930
+ checksPassed: baseChecks + headChecks + deltaValidation.checksPassed,
931
+ checkCount:
932
+ baseResult.checks.checks.length +
933
+ headResult.checks.checks.length +
934
+ deltaValidation.checkCount,
935
+ baseComposition: baseResult.checks.composition.status,
936
+ headComposition: headResult.checks.composition.status,
937
+ },
938
+ }
939
+ fs.writeFileSync(
940
+ receiptCandidate,
941
+ `${JSON.stringify(finalReceipt, null, 2)}\n`,
942
+ )
943
+
944
+ try {
945
+ const currentOutput = resolveOutputPath({
946
+ requestedOutput,
947
+ defaultOutput: 'architecture-delta.html',
948
+ inputPaths: [basePath, headPath],
949
+ otherOutputPaths: [receiptPath],
950
+ }).outputPath
951
+ resolveOutputPath({
952
+ requestedOutput: options.receipt || compareReceiptPath(currentOutput),
953
+ defaultOutput: compareReceiptPath(currentOutput),
954
+ requiredExtension: '.json',
955
+ inputPaths: [basePath, headPath],
956
+ otherOutputPaths: [currentOutput],
957
+ })
958
+ } catch (error) {
959
+ const outputDiagnostic = error.archifyDiagnostics?.[0]
960
+ reportCompareFailure({
961
+ json: options.json,
962
+ stage: 'commit',
963
+ error: error.message,
964
+ code: outputDiagnostic?.code || 'output/path-resolution',
965
+ details: {
966
+ ...(outputDiagnostic?.subject || {}),
967
+ ...(outputDiagnostic?.evidence || {}),
968
+ supportedFixes: outputDiagnostic?.supportedFixes || [
969
+ 'restore safe output paths and retry',
970
+ ],
971
+ },
972
+ })
973
+ return
974
+ }
975
+
976
+ commitComparePair({
977
+ htmlCandidate,
978
+ receiptCandidate,
979
+ outputPath,
980
+ receiptPath,
981
+ stagingDirectory,
982
+ })
983
+ if (options.json) console.log(JSON.stringify(finalReceipt, null, 2))
984
+ else {
985
+ console.log(`compared architecture ${outputPath}`)
986
+ console.log(
987
+ `${finalReceipt.validation.checksPassed}/${finalReceipt.validation.checkCount} checks; completeness ${finalReceipt.completeness}; ${finalReceipt.proofLevel}; sha256 ${finalReceipt.artifact.sha256.slice(0, 12)}`,
988
+ )
989
+ console.log(`receipt ${receiptPath}`)
990
+ }
991
+ } catch (error) {
992
+ if (error instanceof ArchitectureDeltaError) {
993
+ reportCompareFailure({
994
+ json: options.json,
995
+ stage: 'artifact',
996
+ error: error.message,
997
+ code: error.code,
998
+ details: error.details,
999
+ })
1000
+ } else if (error.compareStage === 'commit') {
1001
+ reportCompareFailure({
1002
+ json: options.json,
1003
+ stage: error.compareStage,
1004
+ error: error.message,
1005
+ code: error.compareCode,
1006
+ details: error.compareDetails,
1007
+ })
1008
+ } else {
1009
+ reportCompareFailure({
1010
+ json: options.json,
1011
+ stage: 'internal',
1012
+ error: 'Architecture compare failed before commit.',
1013
+ code: 'delta/internal',
1014
+ details: { reason: error.message },
1015
+ })
1016
+ }
1017
+ } finally {
1018
+ try {
1019
+ fs.rmSync(stagingDirectory, { recursive: true, force: true })
1020
+ } catch (error) {
1021
+ console.error(
1022
+ `Warning: could not remove compare staging directory: ${error.message}`,
1023
+ )
1024
+ }
1025
+ }
1026
+ }
1027
+
1028
+ function commandRender(args) {
1029
+ const qualityArgs = extractQualityArgs(args)
1030
+ const repoArgs = extractRepoRootArgs(qualityArgs.rest)
1031
+ // render takes no options of its own once --quality and --repo-root are
1032
+ // stripped, so anything left starting with -- is a typo. Without this a
1033
+ // mistyped flag was taken as the output path: `render architecture spec.json
1034
+ // --json out.html` wrote a file literally named `--json` and never wrote
1035
+ // out.html, exiting 0. Every sibling subcommand already guards this.
1036
+ const unknown = repoArgs.rest.filter(arg => arg.startsWith('--'))
1037
+ if (unknown.length) fail(`Unknown render option "${unknown[0]}".`)
1038
+ const [type, input, output] = repoArgs.rest
1039
+ if (!type || !input || repoArgs.rest.length > 3) fail(usage())
1040
+ assertEvidenceType(type, repoArgs.repoRoot)
1041
+ const result = runNode(
1042
+ [rendererPath(type), input, ...(output ? [output] : [])],
1043
+ {
1044
+ env: rendererEnv(qualityArgs.quality, repoArgs.repoRoot),
1045
+ },
1046
+ )
1047
+ if (result.status !== 0) exitFrom(result)
1048
+ }
1049
+
1050
+ function reportArtifactFailure({
1051
+ command,
1052
+ json,
1053
+ stage,
1054
+ type,
1055
+ input,
1056
+ output,
1057
+ error,
1058
+ diagnostics = [],
1059
+ status = 1,
1060
+ checker,
1061
+ }) {
1062
+ const receipt = {
1063
+ schemaVersion: 1,
1064
+ ok: false,
1065
+ command,
1066
+ stage,
1067
+ type,
1068
+ input,
1069
+ ...(output === undefined ? {} : { output }),
1070
+ error,
1071
+ diagnostics,
1072
+ ...(checker ? { checker } : {}),
1073
+ }
1074
+ if (json) console.log(JSON.stringify(receipt, null, 2))
1075
+ else console.error(formatDiagnostics(error, diagnostics))
1076
+ process.exitCode = status
1077
+ }
1078
+
1079
+ function reportDeliveryFailure(options) {
1080
+ reportArtifactFailure({ ...options, command: 'deliver' })
1081
+ }
1082
+
1083
+ function reportValidateFailure(options) {
1084
+ reportArtifactFailure({ ...options, command: 'validate' })
1085
+ }
1086
+
1087
+ function reportArtifactArgumentFailure(command, error) {
1088
+ const details = error.archifyArgument || {}
1089
+ reportArtifactFailure({
1090
+ command,
1091
+ json: true,
1092
+ stage: 'arguments',
1093
+ error: error.message,
1094
+ diagnostics: [
1095
+ diagnostic({
1096
+ code: details.code || 'cli/invalid-arguments',
1097
+ message: error.message,
1098
+ subject: { command, ...(details.subject || {}) },
1099
+ evidence: details.evidence || {},
1100
+ supportedFixes: details.supportedFixes || [
1101
+ 'correct the command arguments and retry',
1102
+ ],
1103
+ }),
1104
+ ],
1105
+ status: 2,
1106
+ })
1107
+ }
1108
+
1109
+ function sourceEvidenceFromArtifact(artifact) {
1110
+ const html = artifact.toString('utf8')
1111
+ const match = html.match(
1112
+ /<script id="archify-source-evidence-data" type="application\/json">([\s\S]*?)<\/script>/,
1113
+ )
1114
+ if (!match) return null
1115
+ const evidence = JSON.parse(match[1])
1116
+ if (
1117
+ evidence?.verified !== true ||
1118
+ !evidence.repository?.url ||
1119
+ !evidence.repository?.revision ||
1120
+ !Number.isInteger(evidence.referenceCount)
1121
+ ) {
1122
+ throw new Error('Rendered source evidence receipt is incomplete.')
1123
+ }
1124
+ return evidence
1125
+ }
1126
+
1127
+ function engineeringProfileFromArtifact(artifact) {
1128
+ const match = artifact
1129
+ .toString('utf8')
1130
+ .match(/<svg[^>]*\sdata-engineering-profile="([^"]+)"/)
1131
+ return match ? match[1] : null
1132
+ }
1133
+
1134
+ async function commandDeliver(args) {
1135
+ const qualityArgs = extractQualityArgs(args)
1136
+ const repoArgs = extractRepoRootArgs(qualityArgs.rest)
1137
+ const json = repoArgs.rest.includes('--json')
1138
+ const open = repoArgs.rest.includes('--open')
1139
+ const knownOptions = new Set(['--json', '--open'])
1140
+ const unknown = repoArgs.rest.filter(
1141
+ arg => arg.startsWith('--') && !knownOptions.has(arg),
1142
+ )
1143
+ if (unknown.length)
1144
+ rejectCliArgument(`Unknown deliver option "${unknown[0]}".`, {
1145
+ code: 'cli/unknown-option',
1146
+ subject: { option: unknown[0] },
1147
+ supportedFixes: ['remove the unknown option and retry'],
1148
+ })
1149
+ const positional = repoArgs.rest.filter(arg => !knownOptions.has(arg))
1150
+ const [type, input, requestedOutput] = positional
1151
+ if (!type || !input || positional.length > 3)
1152
+ rejectCliArgument(usage(), {
1153
+ code: 'cli/usage',
1154
+ supportedFixes: [
1155
+ 'use: archify deliver <type> <input.json> [output.html] [options]',
1156
+ ],
1157
+ })
1158
+ assertEvidenceType(type, repoArgs.repoRoot)
1159
+ const renderer = rendererPath(type)
1160
+ const { resolveOutputPath } = await import(
1161
+ '../renderers/shared/output-path.mjs'
1162
+ )
1163
+ const inputPath = path.resolve(input)
1164
+ let specification
1165
+ let diagram
1166
+ try {
1167
+ specification = fs.readFileSync(inputPath)
1168
+ diagram = JSON.parse(specification.toString('utf8'))
1169
+ } catch (error) {
1170
+ const repair = inputDiagnostic(error, inputPath)
1171
+ reportDeliveryFailure({
1172
+ json,
1173
+ stage: 'input',
1174
+ type,
1175
+ input: inputPath,
1176
+ output: path.resolve(requestedOutput || `${type}.html`),
1177
+ error: `Could not read delivery input "${inputPath}": ${error.message}`,
1178
+ diagnostics: [repair],
1179
+ })
1180
+ return
1181
+ }
1182
+
1183
+ const authoredOutput =
1184
+ typeof diagram?.meta?.output === 'string' && diagram.meta.output
1185
+ ? diagram.meta.output
1186
+ : undefined
1187
+ let outputPath
1188
+ try {
1189
+ ;({ outputPath } = resolveOutputPath({
1190
+ requestedOutput,
1191
+ authoredOutput,
1192
+ defaultOutput: `${type}.html`,
1193
+ inputPaths: [inputPath],
1194
+ }))
1195
+ } catch (error) {
1196
+ const attemptedOutput = path.resolve(
1197
+ requestedOutput || authoredOutput || `${type}.html`,
1198
+ )
1199
+ reportDeliveryFailure({
1200
+ json,
1201
+ stage: 'prepare',
1202
+ type,
1203
+ input: inputPath,
1204
+ output: attemptedOutput,
1205
+ error: error.message,
1206
+ diagnostics: error.archifyDiagnostics || [
1207
+ diagnostic({
1208
+ code: 'output/path-resolution',
1209
+ message: error.message,
1210
+ subject: { output: attemptedOutput },
1211
+ evidence: { ...(error?.code ? { systemCode: error.code } : {}) },
1212
+ supportedFixes: ['choose a safe output path and retry'],
1213
+ }),
1214
+ ],
1215
+ })
1216
+ return
1217
+ }
1218
+ const outputDirectory = path.dirname(outputPath)
1219
+ try {
1220
+ fs.mkdirSync(outputDirectory, { recursive: true })
1221
+ } catch (error) {
1222
+ const message = `Could not create delivery directory "${outputDirectory}": ${error.message}`
1223
+ reportDeliveryFailure({
1224
+ json,
1225
+ stage: 'prepare',
1226
+ type,
1227
+ input: inputPath,
1228
+ output: outputPath,
1229
+ error: message,
1230
+ diagnostics: [
1231
+ diagnostic({
1232
+ code: 'delivery/prepare-directory',
1233
+ message,
1234
+ subject: { outputDirectory },
1235
+ evidence: {
1236
+ ...(error?.code ? { systemCode: error.code } : {}),
1237
+ reason: error.message,
1238
+ },
1239
+ supportedFixes: ['choose a writable output directory'],
1240
+ }),
1241
+ ],
1242
+ })
1243
+ return
1244
+ }
1245
+
1246
+ // Keep the candidate beside the target so the final rename is one
1247
+ // same-filesystem commit. A render or artifact-check failure never touches
1248
+ // an existing trusted output.
1249
+ let stagingDirectory
1250
+ try {
1251
+ stagingDirectory = fs.mkdtempSync(
1252
+ path.join(outputDirectory, '.archify-delivery-'),
1253
+ )
1254
+ } catch (error) {
1255
+ const message = `Could not create a delivery candidate beside "${outputPath}": ${error.message}`
1256
+ reportDeliveryFailure({
1257
+ json,
1258
+ stage: 'prepare',
1259
+ type,
1260
+ input: inputPath,
1261
+ output: outputPath,
1262
+ error: message,
1263
+ diagnostics: [
1264
+ diagnostic({
1265
+ code: 'delivery/prepare-candidate',
1266
+ message,
1267
+ subject: { output: outputPath },
1268
+ evidence: {
1269
+ ...(error?.code ? { systemCode: error.code } : {}),
1270
+ reason: error.message,
1271
+ },
1272
+ supportedFixes: [
1273
+ 'choose a writable output directory on the target filesystem',
1274
+ ],
1275
+ }),
1276
+ ],
1277
+ })
1278
+ return
1279
+ }
1280
+ const candidatePath = path.join(stagingDirectory, path.basename(outputPath))
1281
+ const specificationSnapshotPath = path.join(
1282
+ stagingDirectory,
1283
+ 'specification.snapshot.json',
1284
+ )
1285
+
1286
+ try {
1287
+ try {
1288
+ fs.writeFileSync(specificationSnapshotPath, specification, { flag: 'wx' })
1289
+ } catch (error) {
1290
+ const message = `Could not freeze the delivery specification: ${error.message}`
1291
+ reportDeliveryFailure({
1292
+ json,
1293
+ stage: 'prepare',
1294
+ type,
1295
+ input: inputPath,
1296
+ output: outputPath,
1297
+ error: message,
1298
+ diagnostics: [
1299
+ diagnostic({
1300
+ code: 'delivery/freeze-specification',
1301
+ message,
1302
+ subject: { input: inputPath },
1303
+ evidence: {
1304
+ ...(error?.code ? { systemCode: error.code } : {}),
1305
+ reason: error.message,
1306
+ },
1307
+ supportedFixes: [
1308
+ 'choose a writable output directory on the target filesystem',
1309
+ ],
1310
+ }),
1311
+ ],
1312
+ })
1313
+ return
1314
+ }
1315
+
1316
+ const render = runNode(
1317
+ [renderer, specificationSnapshotPath, candidatePath],
1318
+ {
1319
+ stdio: 'pipe',
1320
+ env: rendererEnv(qualityArgs.quality, repoArgs.repoRoot, true),
1321
+ },
1322
+ )
1323
+ if (render.status !== 0) {
1324
+ const failure = rendererFailure(render)
1325
+ reportDeliveryFailure({
1326
+ json,
1327
+ stage: 'render',
1328
+ type,
1329
+ input: inputPath,
1330
+ output: outputPath,
1331
+ error: failure.error,
1332
+ diagnostics: failure.diagnostics,
1333
+ status: render.status ?? 1,
1334
+ })
1335
+ return
1336
+ }
1337
+
1338
+ const check = runNode(
1339
+ [path.join(skillRoot, 'scripts/check-render-output.mjs'), candidatePath],
1340
+ {
1341
+ stdio: 'pipe',
1342
+ },
1343
+ )
1344
+ if (check.status !== 0) {
1345
+ if (check.stderr) process.stderr.write(check.stderr)
1346
+ let checker
1347
+ try {
1348
+ checker = JSON.parse(check.stdout)
1349
+ checker.file = outputPath
1350
+ } catch {
1351
+ checker = {
1352
+ ok: false,
1353
+ file: outputPath,
1354
+ diagnostic: check.stdout.trim(),
1355
+ }
1356
+ }
1357
+ reportDeliveryFailure({
1358
+ json,
1359
+ stage: 'check',
1360
+ type,
1361
+ input: inputPath,
1362
+ output: outputPath,
1363
+ error:
1364
+ 'Final artifact check failed; the previous artifact was preserved.',
1365
+ diagnostics: checkerDiagnostics(checker),
1366
+ status: check.status ?? 1,
1367
+ checker,
1368
+ })
1369
+ return
1370
+ }
1371
+
1372
+ let result
1373
+ try {
1374
+ result = JSON.parse(check.stdout)
1375
+ } catch (error) {
1376
+ const message = `Could not parse the successful artifact-check receipt: ${error.message}`
1377
+ reportDeliveryFailure({
1378
+ json,
1379
+ stage: 'receipt',
1380
+ type,
1381
+ input: inputPath,
1382
+ output: outputPath,
1383
+ error: message,
1384
+ diagnostics: [
1385
+ diagnostic({
1386
+ code: 'delivery/receipt-invalid',
1387
+ message,
1388
+ subject: { output: outputPath },
1389
+ evidence: { reason: error.message },
1390
+ }),
1391
+ ],
1392
+ })
1393
+ return
1394
+ }
1395
+ let artifact
1396
+ try {
1397
+ artifact = fs.readFileSync(candidatePath)
1398
+ } catch (error) {
1399
+ const message = `Could not read the verified delivery candidate: ${error.message}`
1400
+ reportDeliveryFailure({
1401
+ json,
1402
+ stage: 'receipt',
1403
+ type,
1404
+ input: inputPath,
1405
+ output: outputPath,
1406
+ error: message,
1407
+ diagnostics: [
1408
+ diagnostic({
1409
+ code: 'delivery/candidate-unreadable',
1410
+ message,
1411
+ subject: { output: outputPath },
1412
+ evidence: {
1413
+ ...(error?.code ? { systemCode: error.code } : {}),
1414
+ reason: error.message,
1415
+ },
1416
+ }),
1417
+ ],
1418
+ })
1419
+ return
1420
+ }
1421
+ let sourceEvidence
1422
+ try {
1423
+ sourceEvidence = sourceEvidenceFromArtifact(artifact)
1424
+ } catch (error) {
1425
+ const message = `Could not read the repository evidence receipt: ${error.message}`
1426
+ reportDeliveryFailure({
1427
+ json,
1428
+ stage: 'receipt',
1429
+ type,
1430
+ input: inputPath,
1431
+ output: outputPath,
1432
+ error: message,
1433
+ diagnostics: [
1434
+ diagnostic({
1435
+ code: 'delivery/evidence-receipt-invalid',
1436
+ message,
1437
+ subject: { output: outputPath },
1438
+ evidence: { reason: error.message },
1439
+ }),
1440
+ ],
1441
+ })
1442
+ return
1443
+ }
1444
+ const engineeringProfile = engineeringProfileFromArtifact(artifact)
1445
+ const receipt = {
1446
+ schemaVersion: 1,
1447
+ ok: true,
1448
+ command: 'deliver',
1449
+ type,
1450
+ input: inputPath,
1451
+ output: outputPath,
1452
+ specification: {
1453
+ sha256: createHash('sha256').update(specification).digest('hex'),
1454
+ bytes: specification.byteLength,
1455
+ },
1456
+ artifact: {
1457
+ sha256: createHash('sha256').update(artifact).digest('hex'),
1458
+ bytes: artifact.byteLength,
1459
+ },
1460
+ validation: {
1461
+ checksPassed: result.checks.filter(checkItem => checkItem.ok).length,
1462
+ checkCount: result.checks.length,
1463
+ compositionProfile: result.composition.profile,
1464
+ compositionStatus: result.composition.status,
1465
+ ...(engineeringProfile ? { engineeringProfile } : {}),
1466
+ errors: result.composition.summary.errors,
1467
+ warnings: result.composition.summary.warnings,
1468
+ },
1469
+ ...(sourceEvidence
1470
+ ? {
1471
+ evidence: {
1472
+ verified: true,
1473
+ repository: sourceEvidence.repository.url,
1474
+ revision: sourceEvidence.repository.revision,
1475
+ references: sourceEvidence.referenceCount,
1476
+ ...(sourceEvidence.repository.linkMode
1477
+ ? { linkMode: sourceEvidence.repository.linkMode }
1478
+ : {}),
1479
+ },
1480
+ }
1481
+ : {}),
1482
+ }
1483
+
1484
+ try {
1485
+ resolveOutputPath({
1486
+ requestedOutput,
1487
+ authoredOutput,
1488
+ defaultOutput: `${type}.html`,
1489
+ inputPaths: [inputPath],
1490
+ })
1491
+ } catch (error) {
1492
+ reportDeliveryFailure({
1493
+ json,
1494
+ stage: 'commit',
1495
+ type,
1496
+ input: inputPath,
1497
+ output: outputPath,
1498
+ error: error.message,
1499
+ diagnostics: error.archifyDiagnostics || [
1500
+ diagnostic({
1501
+ code: 'output/path-resolution',
1502
+ message: error.message,
1503
+ subject: { output: outputPath },
1504
+ evidence: { ...(error?.code ? { systemCode: error.code } : {}) },
1505
+ supportedFixes: ['restore a safe output path and retry'],
1506
+ }),
1507
+ ],
1508
+ })
1509
+ return
1510
+ }
1511
+
1512
+ try {
1513
+ fs.renameSync(candidatePath, outputPath)
1514
+ } catch (error) {
1515
+ const message = `Could not commit verified delivery "${outputPath}": ${error.message}`
1516
+ reportDeliveryFailure({
1517
+ json,
1518
+ stage: 'commit',
1519
+ type,
1520
+ input: inputPath,
1521
+ output: outputPath,
1522
+ error: message,
1523
+ diagnostics: [
1524
+ diagnostic({
1525
+ code: 'delivery/commit',
1526
+ message,
1527
+ subject: { output: outputPath },
1528
+ evidence: {
1529
+ ...(error?.code ? { systemCode: error.code } : {}),
1530
+ reason: error.message,
1531
+ },
1532
+ supportedFixes: [
1533
+ 'choose a replaceable file target on the same writable filesystem',
1534
+ ],
1535
+ }),
1536
+ ],
1537
+ })
1538
+ return
1539
+ }
1540
+
1541
+ if (open) {
1542
+ try {
1543
+ const { openArtifact } = await import('./open-artifact.mjs')
1544
+ receipt.open = openArtifact(outputPath)
1545
+ } catch {
1546
+ receipt.open = {
1547
+ requested: true,
1548
+ status: 'unsupported',
1549
+ target: outputPath,
1550
+ method: null,
1551
+ }
1552
+ }
1553
+ if (receipt.open.status !== 'opened') {
1554
+ console.error(
1555
+ `Could not open the verified artifact (${receipt.open.status}). Open it manually: ${outputPath}`,
1556
+ )
1557
+ }
1558
+ }
1559
+
1560
+ if (json) {
1561
+ console.log(JSON.stringify(receipt, null, 2))
1562
+ } else {
1563
+ console.log(`delivered ${type} ${outputPath}`)
1564
+ const engineering = receipt.validation.engineeringProfile
1565
+ ? `; engineering ${receipt.validation.engineeringProfile}: pass`
1566
+ : ''
1567
+ console.log(
1568
+ `${receipt.validation.checksPassed}/${receipt.validation.checkCount} artifact checks; composition ${receipt.validation.compositionProfile}: ${receipt.validation.compositionStatus}${engineering}; sha256 ${receipt.artifact.sha256.slice(0, 12)}`,
1569
+ )
1570
+ if (receipt.open?.status === 'opened') console.log(`opened ${outputPath}`)
1571
+ }
1572
+ } finally {
1573
+ try {
1574
+ fs.rmSync(stagingDirectory, { recursive: true, force: true })
1575
+ } catch (error) {
1576
+ console.error(
1577
+ `Warning: could not remove delivery staging directory "${stagingDirectory}": ${error.message}`,
1578
+ )
1579
+ }
1580
+ }
1581
+ }
1582
+
1583
+ async function commandPreview(args) {
1584
+ const qualityArgs = extractQualityArgs(args)
1585
+ const repoArgs = extractRepoRootArgs(qualityArgs.rest)
1586
+ const noOpen = repoArgs.rest.includes('--no-open')
1587
+ const knownOptions = new Set(['--no-open'])
1588
+ const unknown = repoArgs.rest.filter(
1589
+ arg => arg.startsWith('--') && !knownOptions.has(arg),
1590
+ )
1591
+ if (unknown.length) fail(`Unknown preview option "${unknown[0]}".`)
1592
+ const positional = repoArgs.rest.filter(arg => !knownOptions.has(arg))
1593
+ const [type, input, output] = positional
1594
+ if (!type || !input || positional.length > 3) fail(usage())
1595
+ assertEvidenceType(type, repoArgs.repoRoot)
1596
+ rendererPath(type)
1597
+
1598
+ let runPreview
1599
+ try {
1600
+ ;({ runPreview } = await import('./preview.mjs'))
1601
+ } catch (error) {
1602
+ fail(`Could not load live preview: ${error.message}`, 1)
1603
+ }
1604
+ try {
1605
+ await runPreview({
1606
+ type,
1607
+ input,
1608
+ output,
1609
+ quality: qualityArgs.quality,
1610
+ repoRoot: repoArgs.repoRoot,
1611
+ open: !noOpen,
1612
+ })
1613
+ } catch (error) {
1614
+ fail(`Could not start live preview: ${error.message}`, 1)
1615
+ }
1616
+ }
1617
+
1618
+ function commandCheck(args) {
1619
+ const [html] = args
1620
+ if (!html) fail(usage())
1621
+ const result = runNode([
1622
+ path.join(skillRoot, 'scripts/check-render-output.mjs'),
1623
+ html,
1624
+ ])
1625
+ if (result.status !== 0) exitFrom(result)
1626
+ }
1627
+
1628
+ async function commandVisualCheck(args) {
1629
+ const json = args.includes('--json')
1630
+ const knownOptions = new Set(['--json'])
1631
+ const unknown = args.filter(
1632
+ arg => arg.startsWith('--') && !knownOptions.has(arg),
1633
+ )
1634
+ if (unknown.length) fail(`Unknown visual-check option "${unknown[0]}".`, 1)
1635
+ const positional = args.filter(arg => !knownOptions.has(arg))
1636
+ if (positional.length !== 1) fail(usage(), 1)
1637
+
1638
+ let runVisualCheck
1639
+ try {
1640
+ ;({ runVisualCheck } = await import('./visual-check.mjs'))
1641
+ } catch (error) {
1642
+ fail(`Could not load visual-check: ${error.message}`, 1)
1643
+ }
1644
+
1645
+ let result
1646
+ try {
1647
+ result = await runVisualCheck({ artifactPath: positional[0] })
1648
+ } catch (error) {
1649
+ if (json) {
1650
+ console.log(
1651
+ JSON.stringify(
1652
+ {
1653
+ schemaVersion: 1,
1654
+ ok: false,
1655
+ command: 'visual-check',
1656
+ evidenceKind: 'automated-browser',
1657
+ status: 'fail',
1658
+ visualReview: 'pending',
1659
+ artifact: { path: path.resolve(positional[0]) },
1660
+ error: error.message,
1661
+ },
1662
+ null,
1663
+ 2,
1664
+ ),
1665
+ )
1666
+ } else {
1667
+ console.error(`automated browser evidence failed: ${error.message}`)
1668
+ console.error('perceptual visual review pending')
1669
+ }
1670
+ process.exitCode = 1
1671
+ return
1672
+ }
1673
+
1674
+ if (json) {
1675
+ console.log(JSON.stringify(result.receipt, null, 2))
1676
+ } else {
1677
+ console.log(
1678
+ `automated browser evidence ${result.receipt.status}: ${result.receipt.artifact.path}`,
1679
+ )
1680
+ console.log(
1681
+ `visual-check containment ${result.receipt.containment.status}; captures ${result.receipt.captures.status}; perceptual visual review pending`,
1682
+ )
1683
+ console.log(
1684
+ `receipt ${path.join(path.dirname(result.receipt.artifact.path), result.receipt.sidecars.receipt)}`,
1685
+ )
1686
+ if (result.receipt.captures.contactSheet) {
1687
+ console.log(
1688
+ `contact sheet ${path.join(path.dirname(result.receipt.artifact.path), result.receipt.captures.contactSheet)}`,
1689
+ )
1690
+ }
1691
+ if (result.receipt.error) console.error(result.receipt.error)
1692
+ }
1693
+ process.exitCode = result.exitCode
1694
+ }
1695
+
1696
+ function commandExamples() {
1697
+ const result = runNode(
1698
+ [path.join(skillRoot, 'scripts/render-examples.mjs')],
1699
+ { cwd: skillRoot },
1700
+ )
1701
+ if (result.status !== 0) exitFrom(result)
1702
+ }
1703
+
1704
+ async function commandDoctor() {
1705
+ const checks = []
1706
+ const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10)
1707
+ checks.push({
1708
+ label: `Node.js v${process.versions.node} (requires >=18)`,
1709
+ ok: nodeMajor >= 18,
1710
+ missing: 0,
1711
+ failureLabel: 'unsupported',
1712
+ })
1713
+
1714
+ const template = path.join(skillRoot, 'assets/template.html')
1715
+ checks.push({
1716
+ label: 'Core template',
1717
+ ok: fs.existsSync(template),
1718
+ missing: fs.existsSync(template) ? 0 : 1,
1719
+ })
1720
+
1721
+ const examplesRenderer = path.join(skillRoot, 'scripts/render-examples.mjs')
1722
+ checks.push({
1723
+ label: 'Example renderer',
1724
+ ok: fs.existsSync(examplesRenderer),
1725
+ missing: fs.existsSync(examplesRenderer) ? 0 : 1,
1726
+ })
1727
+
1728
+ const previewRuntime = path.join(skillRoot, 'bin/preview.mjs')
1729
+ checks.push({
1730
+ label: 'Live preview runtime',
1731
+ ok: fs.existsSync(previewRuntime),
1732
+ missing: fs.existsSync(previewRuntime) ? 0 : 1,
1733
+ })
1734
+
1735
+ const visualCheckRuntime = path.join(skillRoot, 'bin/visual-check.mjs')
1736
+ checks.push({
1737
+ label: 'Visual-check runtime',
1738
+ ok: fs.existsSync(visualCheckRuntime),
1739
+ missing: fs.existsSync(visualCheckRuntime) ? 0 : 1,
1740
+ })
1741
+
1742
+ const outputPathRuntime = path.join(
1743
+ skillRoot,
1744
+ 'renderers/shared/output-path.mjs',
1745
+ )
1746
+ checks.push({
1747
+ label: 'Output path safety runtime',
1748
+ ok: fs.existsSync(outputPathRuntime),
1749
+ missing: fs.existsSync(outputPathRuntime) ? 0 : 1,
1750
+ })
1751
+
1752
+ const scenarioGuide = path.join(skillRoot, 'recipes/scenarios.mjs')
1753
+ checks.push({
1754
+ label: 'Scenario recipe guide',
1755
+ ok: fs.existsSync(scenarioGuide),
1756
+ missing: fs.existsSync(scenarioGuide) ? 0 : 1,
1757
+ })
1758
+
1759
+ const authoringReferences = [
1760
+ path.join(skillRoot, 'references', 'authoring-contract.md'),
1761
+ path.join(skillRoot, 'references', 'viewer-runtime.md'),
1762
+ path.join(skillRoot, 'references', 'delivery-contract.md'),
1763
+ ]
1764
+ const authoringReferencesMissing = authoringReferences.filter(
1765
+ file => !fs.existsSync(file),
1766
+ ).length
1767
+ checks.push({
1768
+ label: 'Progressive authoring references',
1769
+ ok: authoringReferencesMissing === 0,
1770
+ missing: authoringReferencesMissing,
1771
+ })
1772
+
1773
+ const compareRuntime = path.join(skillRoot, 'delta/architecture-delta.mjs')
1774
+ const compareFixtures = [
1775
+ path.join(skillRoot, 'examples/checkout-platform.base.architecture.json'),
1776
+ path.join(skillRoot, 'examples/checkout-platform.head.architecture.json'),
1777
+ ]
1778
+ const compareMissing = [compareRuntime, ...compareFixtures].filter(
1779
+ file => !fs.existsSync(file),
1780
+ ).length
1781
+ checks.push({
1782
+ label: 'Architecture compare runtime and proof fixtures',
1783
+ ok: compareMissing === 0,
1784
+ missing: compareMissing,
1785
+ })
1786
+
1787
+ const validators = path.join(
1788
+ skillRoot,
1789
+ 'renderers/shared/generated-validators.mjs',
1790
+ )
1791
+ const validatorsExist = fs.existsSync(validators)
1792
+ let validatorsValid = false
1793
+ if (validatorsExist) {
1794
+ try {
1795
+ const module = await import(
1796
+ `${pathToFileURL(validators).href}?doctor=${Date.now()}`
1797
+ )
1798
+ validatorsValid = [...TYPES].every(
1799
+ type => typeof module[type] === 'function',
1800
+ )
1801
+ } catch {
1802
+ validatorsValid = false
1803
+ }
1804
+ }
1805
+ checks.push({
1806
+ label: 'Standalone schema validators',
1807
+ ok: validatorsValid,
1808
+ missing: validatorsExist ? 0 : 1,
1809
+ invalid: validatorsExist && !validatorsValid ? 1 : 0,
1810
+ failureLabel: validatorsExist ? 'invalid' : 'missing',
1811
+ })
1812
+
1813
+ const examples = {
1814
+ architecture: 'web-app.architecture.json',
1815
+ workflow: 'agent-tool-call.workflow.json',
1816
+ sequence: 'cache-miss-request.sequence.json',
1817
+ dataflow: 'product-analytics.dataflow.json',
1818
+ lifecycle: 'agent-run.lifecycle.json',
1819
+ }
1820
+
1821
+ for (const type of TYPES) {
1822
+ const required = [
1823
+ path.join(skillRoot, 'renderers', type, `render-${type}.mjs`),
1824
+ path.join(skillRoot, 'schemas', `${type}.schema.json`),
1825
+ path.join(skillRoot, 'examples', examples[type]),
1826
+ ]
1827
+ const missing = required.filter(file => !fs.existsSync(file)).length
1828
+ checks.push({
1829
+ label: `${type} renderer, schema, and example`,
1830
+ ok: missing === 0,
1831
+ missing,
1832
+ })
1833
+ }
1834
+
1835
+ console.log('Archify doctor\n')
1836
+ for (const check of checks) {
1837
+ console.log(
1838
+ `[${check.ok ? 'ok' : check.failureLabel || 'missing'}] ${check.label}`,
1839
+ )
1840
+ }
1841
+
1842
+ const nodeFailed = checks[0].ok ? 0 : 1
1843
+ const missingFiles = checks.reduce((count, check) => count + check.missing, 0)
1844
+ const invalidRuntime = checks.reduce(
1845
+ (count, check) => count + (check.invalid || 0),
1846
+ 0,
1847
+ )
1848
+ if (nodeFailed === 0 && missingFiles === 0 && invalidRuntime === 0) {
1849
+ console.log('\nArchify is ready.')
1850
+ return
1851
+ }
1852
+
1853
+ const problems = []
1854
+ if (nodeFailed) problems.push('Node.js 18 or newer is required')
1855
+ if (missingFiles)
1856
+ problems.push(
1857
+ `${missingFiles} required file${missingFiles === 1 ? '' : 's'} missing`,
1858
+ )
1859
+ if (invalidRuntime)
1860
+ problems.push(
1861
+ `${invalidRuntime} runtime check${invalidRuntime === 1 ? '' : 's'} failed`,
1862
+ )
1863
+ console.error(`\nArchify is not ready: ${problems.join('; ')}.`)
1864
+ process.exitCode = 1
1865
+ }
1866
+
1867
+ async function commandGuide(args) {
1868
+ let lang
1869
+ let json = false
1870
+ const queryParts = []
1871
+
1872
+ for (let index = 0; index < args.length; index += 1) {
1873
+ const arg = args[index]
1874
+ if (arg === '--json') {
1875
+ json = true
1876
+ } else if (arg === '--lang') {
1877
+ const value = args[index + 1]
1878
+ if (value !== 'en' && value !== 'zh') fail('--lang must be "en" or "zh".')
1879
+ lang = value
1880
+ index += 1
1881
+ } else if (arg.startsWith('--lang=')) {
1882
+ const value = arg.slice('--lang='.length)
1883
+ if (value !== 'en' && value !== 'zh') fail('--lang must be "en" or "zh".')
1884
+ lang = value
1885
+ } else if (arg.startsWith('--')) {
1886
+ fail(`Unknown guide option "${arg}".`)
1887
+ } else {
1888
+ queryParts.push(arg)
1889
+ }
1890
+ }
1891
+
1892
+ const guidePath = path.join(skillRoot, 'recipes/scenarios.mjs')
1893
+ let guide
1894
+ try {
1895
+ guide = await import(pathToFileURL(guidePath).href)
1896
+ } catch (error) {
1897
+ fail(`Could not load the scenario recipe guide: ${error.message}`, 1)
1898
+ }
1899
+
1900
+ const query = queryParts.join(' ').trim()
1901
+ if (!query) {
1902
+ const selectedLang = lang || 'en'
1903
+ if (json) {
1904
+ console.log(
1905
+ JSON.stringify(
1906
+ {
1907
+ ok: true,
1908
+ mode: 'list',
1909
+ lang: selectedLang,
1910
+ recipes: guide.listScenarioRecipes(selectedLang),
1911
+ },
1912
+ null,
1913
+ 2,
1914
+ ),
1915
+ )
1916
+ } else {
1917
+ console.log(guide.formatScenarioList(selectedLang))
1918
+ }
1919
+ return
1920
+ }
1921
+
1922
+ const result = guide.recommendScenario(query, lang ? { lang } : {})
1923
+ console.log(
1924
+ json
1925
+ ? JSON.stringify(result, null, 2)
1926
+ : guide.formatScenarioRecommendation(result),
1927
+ )
1928
+ }
1929
+
1930
+ async function commandBrands(args) {
1931
+ const json = args.includes('--json')
1932
+ const unknown = args.filter(arg => arg.startsWith('--') && arg !== '--json')
1933
+ if (unknown.length) fail(`Unknown brands option "${unknown[0]}".`)
1934
+ const positional = args.filter(arg => arg !== '--json')
1935
+ if (positional[0] === 'capture') {
1936
+ if (positional.length !== 2)
1937
+ fail('Usage: archify brands capture <url> [--json]')
1938
+ const { captureBrandReference } = await import(
1939
+ '../renderers/shared/brand-marks.mjs'
1940
+ )
1941
+ let capture
1942
+ try {
1943
+ capture = await captureBrandReference(positional[1])
1944
+ } catch (error) {
1945
+ fail(error.message)
1946
+ }
1947
+ const result = {
1948
+ schemaVersion: 1,
1949
+ ok: true,
1950
+ command: 'brands capture',
1951
+ brand: capture.brand,
1952
+ evidence: {
1953
+ status: capture.resolved.status,
1954
+ source: capture.resolved.sourceUrl,
1955
+ ...(capture.resolved.sha256 ? { sha256: capture.resolved.sha256 } : {}),
1956
+ ...(capture.resolved.contentType
1957
+ ? { contentType: capture.resolved.contentType }
1958
+ : {}),
1959
+ },
1960
+ }
1961
+ console.log(
1962
+ json ? JSON.stringify(result, null, 2) : JSON.stringify(result.brand),
1963
+ )
1964
+ return
1965
+ }
1966
+ const query = positional.join(' ').trim()
1967
+ const { listBrandMarks } = await import('../renderers/shared/brand-marks.mjs')
1968
+ const marks = listBrandMarks(query)
1969
+ if (json) {
1970
+ console.log(
1971
+ JSON.stringify(
1972
+ {
1973
+ schemaVersion: 1,
1974
+ ok: true,
1975
+ command: 'brands',
1976
+ query,
1977
+ count: marks.length,
1978
+ marks,
1979
+ fallback:
1980
+ 'Run "archify brands capture <url> --json", then use the returned digest-pinned brand value.',
1981
+ },
1982
+ null,
1983
+ 2,
1984
+ ),
1985
+ )
1986
+ return
1987
+ }
1988
+ if (!marks.length) {
1989
+ console.log(
1990
+ `No built-in brand matched "${query}". Run "archify brands capture <url> --json", then use the returned digest-pinned brand value.`,
1991
+ )
1992
+ return
1993
+ }
1994
+ const grouped = Map.groupBy
1995
+ ? Map.groupBy(marks, mark => mark.category)
1996
+ : marks.reduce(
1997
+ (map, mark) =>
1998
+ map.set(mark.category, [...(map.get(mark.category) || []), mark]),
1999
+ new Map(),
2000
+ )
2001
+ for (const [category, entries] of grouped) {
2002
+ console.log(`${category}: ${entries.map(mark => mark.id).join(', ')}`)
2003
+ }
2004
+ }
2005
+
2006
+ function commandDemo(args) {
2007
+ if (args.length > 1) fail(usage())
2008
+
2009
+ const outputDirectory = path.resolve(args[0] || process.cwd())
2010
+ const output = path.join(outputDirectory, 'archify-demo.html')
2011
+ const input = path.join(skillRoot, 'examples/web-app.architecture.json')
2012
+
2013
+ try {
2014
+ fs.mkdirSync(outputDirectory, { recursive: true })
2015
+ } catch (error) {
2016
+ fail(
2017
+ `Could not create demo directory "${outputDirectory}": ${error.message}`,
2018
+ 1,
2019
+ )
2020
+ }
2021
+
2022
+ const result = runNode([rendererPath('architecture'), input, output])
2023
+ if (result.status !== 0) exitFrom(result)
2024
+
2025
+ console.log(`\nDemo ready: ${output}`)
2026
+ console.log(
2027
+ 'Next: open the HTML in your browser, then render your own diagram:',
2028
+ )
2029
+ console.log(' archify render architecture <input.json> <output.html>')
2030
+ }
2031
+
2032
+ function migrationPathDiagnostics(error, sourcePath, destinationPath) {
2033
+ if (
2034
+ Array.isArray(error?.archifyDiagnostics) &&
2035
+ error.archifyDiagnostics.length
2036
+ ) {
2037
+ return error.archifyDiagnostics.map(entry => ({
2038
+ ...entry,
2039
+ subject: { ...(entry.subject || {}) },
2040
+ evidence: { ...(entry.evidence || {}) },
2041
+ supportedFixes: [...(entry.supportedFixes || [])],
2042
+ }))
2043
+ }
2044
+ return [
2045
+ diagnostic({
2046
+ code: 'migration/path-preflight',
2047
+ message:
2048
+ 'Could not verify that the workflow migration paths are distinct.',
2049
+ subject: { source: sourcePath, destination: destinationPath },
2050
+ evidence: {
2051
+ ...(error?.code ? { systemCode: error.code } : {}),
2052
+ reason: error?.message || String(error),
2053
+ },
2054
+ supportedFixes: [
2055
+ 'remove unsafe path aliases or choose a different destination path',
2056
+ ],
2057
+ }),
2058
+ ]
2059
+ }
2060
+
2061
+ function migrationReport({
2062
+ ok,
2063
+ sourcePath,
2064
+ destinationPath,
2065
+ sourceBytes,
2066
+ destinationBytes,
2067
+ fromSchemaVersion,
2068
+ preExistingDiagnostics = [],
2069
+ migrationDiagnostics = [],
2070
+ newSchemaDiagnostics = [],
2071
+ changedCoordinates = [],
2072
+ oldRequiredViewBox = null,
2073
+ newRequiredViewBox = null,
2074
+ }) {
2075
+ const report = {
2076
+ ok,
2077
+ command: 'migrate',
2078
+ type: 'workflow',
2079
+ source: {
2080
+ path: sourcePath,
2081
+ ...(sourceBytes
2082
+ ? {
2083
+ sha256: createHash('sha256').update(sourceBytes).digest('hex'),
2084
+ bytes: sourceBytes.length,
2085
+ }
2086
+ : {}),
2087
+ },
2088
+ destination: {
2089
+ path: destinationPath,
2090
+ ...(destinationBytes
2091
+ ? {
2092
+ sha256: createHash('sha256').update(destinationBytes).digest('hex'),
2093
+ bytes: destinationBytes.length,
2094
+ }
2095
+ : {}),
2096
+ },
2097
+ fromSchemaVersion: fromSchemaVersion ?? null,
2098
+ toSchemaVersion: 2,
2099
+ preExistingDiagnostics,
2100
+ migrationDiagnostics,
2101
+ newSchemaDiagnostics,
2102
+ changedCoordinates,
2103
+ oldRequiredViewBox,
2104
+ newRequiredViewBox,
2105
+ }
2106
+ if (!ok) {
2107
+ report.diagnostics = [
2108
+ ...migrationDiagnostics,
2109
+ ...newSchemaDiagnostics,
2110
+ ...preExistingDiagnostics,
2111
+ ]
2112
+ if (!report.diagnostics.length) {
2113
+ report.diagnostics.push(
2114
+ diagnostic({
2115
+ code: 'migration/internal',
2116
+ message: 'Workflow migration failed without a classified diagnostic.',
2117
+ }),
2118
+ )
2119
+ }
2120
+ report.error = report.diagnostics[0].message
2121
+ }
2122
+ return report
2123
+ }
2124
+
2125
+ function extractMigrationOptions(args) {
2126
+ const positional = []
2127
+ let json = false
2128
+ let toSchema
2129
+ for (let index = 0; index < args.length; index += 1) {
2130
+ const arg = args[index]
2131
+ if (arg === '--json') {
2132
+ json = true
2133
+ continue
2134
+ }
2135
+ if (arg === '--to-schema') {
2136
+ toSchema = args[index + 1]
2137
+ if (!toSchema || toSchema.startsWith('--'))
2138
+ fail('--to-schema requires a schema version.')
2139
+ index += 1
2140
+ continue
2141
+ }
2142
+ if (arg.startsWith('--to-schema=')) {
2143
+ toSchema = arg.slice('--to-schema='.length)
2144
+ if (!toSchema) fail('--to-schema requires a schema version.')
2145
+ continue
2146
+ }
2147
+ if (arg.startsWith('--')) fail(`Unknown migrate option "${arg}".`)
2148
+ positional.push(arg)
2149
+ }
2150
+ return { positional, json, toSchema }
2151
+ }
2152
+
2153
+ async function commandMigrate(args) {
2154
+ const options = extractMigrationOptions(args)
2155
+ const [type, sourceArgument, destinationArgument] = options.positional
2156
+ if (
2157
+ type !== 'workflow' ||
2158
+ !sourceArgument ||
2159
+ !destinationArgument ||
2160
+ options.positional.length !== 3 ||
2161
+ options.toSchema !== '2'
2162
+ ) {
2163
+ fail(
2164
+ 'Usage: archify migrate workflow <old.json> <new.json> --to-schema 2 [--json]',
2165
+ )
2166
+ }
2167
+
2168
+ const sourcePath = path.resolve(sourceArgument)
2169
+ const destinationPath = path.resolve(destinationArgument)
2170
+ let sourceBytes
2171
+ let sourceDocument
2172
+ const reportMigrationFailure = ({ status = 1, ...details }) => {
2173
+ const report = migrationReport({
2174
+ ...details,
2175
+ ok: false,
2176
+ sourcePath,
2177
+ destinationPath,
2178
+ sourceBytes,
2179
+ fromSchemaVersion: sourceDocument?.schema_version,
2180
+ })
2181
+ if (options.json) console.log(JSON.stringify(report, null, 2))
2182
+ else console.error(formatDiagnostics(report.error, report.diagnostics))
2183
+ process.exitCode = status
2184
+ }
2185
+ try {
2186
+ sourceBytes = fs.readFileSync(sourcePath)
2187
+ sourceDocument = JSON.parse(sourceBytes.toString('utf8'))
2188
+ } catch (error) {
2189
+ reportMigrationFailure({
2190
+ preExistingDiagnostics: [inputDiagnostic(error, sourcePath)],
2191
+ })
2192
+ return
2193
+ }
2194
+ // Unlike render/validate, migrate has no --quality override. Pin every stage
2195
+ // to the document's durable policy and scrub any ambient profile from the
2196
+ // staged renderer by passing this value explicitly.
2197
+ const activeQualityProfile =
2198
+ sourceDocument?.meta?.quality_profile || 'standard'
2199
+
2200
+ const { pathsAlias } = await import('../renderers/shared/output-path.mjs')
2201
+ let sourceDestinationAlias
2202
+ try {
2203
+ sourceDestinationAlias = pathsAlias(sourcePath, destinationPath)
2204
+ } catch (error) {
2205
+ reportMigrationFailure({
2206
+ migrationDiagnostics: migrationPathDiagnostics(
2207
+ error,
2208
+ sourcePath,
2209
+ destinationPath,
2210
+ ),
2211
+ })
2212
+ return
2213
+ }
2214
+ if (sourceDestinationAlias) {
2215
+ reportMigrationFailure({
2216
+ migrationDiagnostics: [
2217
+ diagnostic({
2218
+ code: 'migration/source-destination',
2219
+ message:
2220
+ 'Workflow migration source and destination must be different files.',
2221
+ subject: { source: sourcePath, destination: destinationPath },
2222
+ supportedFixes: [
2223
+ 'choose a different destination path and keep the source unchanged',
2224
+ ],
2225
+ }),
2226
+ ],
2227
+ })
2228
+ return
2229
+ }
2230
+
2231
+ const { migrateWorkflowDocument, serializeMigratedWorkflow } = await import(
2232
+ '../migrations/workflow-v2.mjs'
2233
+ )
2234
+ let migration
2235
+ try {
2236
+ migration = migrateWorkflowDocument(sourceDocument)
2237
+ } catch (error) {
2238
+ migration = {
2239
+ ok: false,
2240
+ migrationDiagnostics: [
2241
+ diagnostic({
2242
+ code: 'migration/internal',
2243
+ message: 'Workflow migration failed unexpectedly.',
2244
+ evidence: { reason: error.message },
2245
+ supportedFixes: [
2246
+ 'report the source workflow and this diagnostic to the Archify maintainers',
2247
+ ],
2248
+ }),
2249
+ ],
2250
+ }
2251
+ }
2252
+
2253
+ if (!migration.ok) {
2254
+ reportMigrationFailure(migration)
2255
+ return
2256
+ }
2257
+
2258
+ if (
2259
+ fs.existsSync(destinationPath) &&
2260
+ !fs.lstatSync(destinationPath).isFile()
2261
+ ) {
2262
+ reportMigrationFailure({
2263
+ ...migration,
2264
+ migrationDiagnostics: [
2265
+ ...migration.migrationDiagnostics,
2266
+ diagnostic({
2267
+ code: 'migration/destination-type',
2268
+ message:
2269
+ 'Workflow migration destination must be a regular file path.',
2270
+ subject: { destination: destinationPath },
2271
+ supportedFixes: [
2272
+ 'choose a destination path that is absent or names a regular file',
2273
+ ],
2274
+ }),
2275
+ ],
2276
+ })
2277
+ return
2278
+ }
2279
+
2280
+ const destinationDirectory = path.dirname(destinationPath)
2281
+ let stagingDirectory
2282
+ try {
2283
+ fs.mkdirSync(destinationDirectory, { recursive: true })
2284
+ stagingDirectory = fs.mkdtempSync(
2285
+ path.join(destinationDirectory, '.archify-migration-'),
2286
+ )
2287
+ } catch (error) {
2288
+ reportMigrationFailure({
2289
+ ...migration,
2290
+ migrationDiagnostics: [
2291
+ ...migration.migrationDiagnostics,
2292
+ diagnostic({
2293
+ code: 'migration/prepare-destination',
2294
+ message: 'Could not prepare the workflow migration destination.',
2295
+ subject: { destination: destinationPath },
2296
+ evidence: {
2297
+ ...(error?.code ? { systemCode: error.code } : {}),
2298
+ reason: error.message,
2299
+ },
2300
+ supportedFixes: ['choose a writable destination directory'],
2301
+ }),
2302
+ ],
2303
+ })
2304
+ return
2305
+ }
2306
+
2307
+ const candidatePath = path.join(stagingDirectory, 'candidate.workflow.json')
2308
+ const artifactPath = path.join(stagingDirectory, 'migration-check.html')
2309
+ const destinationBytes = Buffer.from(
2310
+ serializeMigratedWorkflow(migration.document),
2311
+ )
2312
+ try {
2313
+ fs.writeFileSync(candidatePath, destinationBytes, { flag: 'wx' })
2314
+ const render = runNode(
2315
+ [rendererPath('workflow'), candidatePath, artifactPath],
2316
+ {
2317
+ stdio: 'pipe',
2318
+ env: rendererEnv(activeQualityProfile, undefined, true),
2319
+ },
2320
+ )
2321
+ if (render.status !== 0) {
2322
+ const failure = rendererFailure(render)
2323
+ reportMigrationFailure({
2324
+ ...migration,
2325
+ newSchemaDiagnostics: [
2326
+ ...migration.newSchemaDiagnostics,
2327
+ ...failure.diagnostics,
2328
+ ],
2329
+ status: render.status ?? 1,
2330
+ })
2331
+ return
2332
+ }
2333
+
2334
+ const check = runNode(
2335
+ [path.join(skillRoot, 'scripts/check-render-output.mjs'), artifactPath],
2336
+ {
2337
+ stdio: 'pipe',
2338
+ },
2339
+ )
2340
+ if (check.status !== 0) {
2341
+ let checker
2342
+ try {
2343
+ checker = JSON.parse(check.stdout)
2344
+ } catch {
2345
+ checker = null
2346
+ }
2347
+ reportMigrationFailure({
2348
+ ...migration,
2349
+ newSchemaDiagnostics: [
2350
+ ...migration.newSchemaDiagnostics,
2351
+ ...checkerDiagnostics(checker),
2352
+ ],
2353
+ status: check.status ?? 1,
2354
+ })
2355
+ return
2356
+ }
2357
+
2358
+ if (pathsAlias(sourcePath, destinationPath)) {
2359
+ reportMigrationFailure({
2360
+ ...migration,
2361
+ migrationDiagnostics: [
2362
+ ...migration.migrationDiagnostics,
2363
+ diagnostic({
2364
+ code: 'migration/source-destination',
2365
+ message:
2366
+ 'Workflow migration source and destination resolved to the same file before commit.',
2367
+ subject: { source: sourcePath, destination: destinationPath },
2368
+ supportedFixes: ['choose a different destination path and retry'],
2369
+ }),
2370
+ ],
2371
+ })
2372
+ return
2373
+ }
2374
+ const currentSourceBytes = fs.readFileSync(sourcePath)
2375
+ if (!currentSourceBytes.equals(sourceBytes)) {
2376
+ reportMigrationFailure({
2377
+ ...migration,
2378
+ migrationDiagnostics: [
2379
+ ...migration.migrationDiagnostics,
2380
+ diagnostic({
2381
+ code: 'migration/source-changed',
2382
+ message:
2383
+ 'Workflow migration source changed while the destination was being verified.',
2384
+ subject: { source: sourcePath },
2385
+ supportedFixes: [
2386
+ 'retry the migration from a stable workflow source file',
2387
+ ],
2388
+ }),
2389
+ ],
2390
+ })
2391
+ return
2392
+ }
2393
+
2394
+ fs.renameSync(candidatePath, destinationPath)
2395
+ const report = migrationReport({
2396
+ ...migration,
2397
+ sourcePath,
2398
+ destinationPath,
2399
+ sourceBytes,
2400
+ destinationBytes,
2401
+ fromSchemaVersion: sourceDocument.schema_version,
2402
+ })
2403
+ if (options.json) console.log(JSON.stringify(report, null, 2))
2404
+ else if (sourceDocument.schema_version === 1) {
2405
+ console.log(
2406
+ `migrated workflow schema v1→v2: ${sourcePath} → ${destinationPath}`,
2407
+ )
2408
+ } else {
2409
+ console.log(
2410
+ `verified workflow schema v2 migration: ${sourcePath} → ${destinationPath}`,
2411
+ )
2412
+ }
2413
+ } catch (error) {
2414
+ const migrationDiagnostics = Array.isArray(error?.archifyDiagnostics)
2415
+ ? migrationPathDiagnostics(error, sourcePath, destinationPath)
2416
+ : [
2417
+ diagnostic({
2418
+ code: 'migration/commit',
2419
+ message: 'Could not commit the verified workflow migration.',
2420
+ subject: { destination: destinationPath },
2421
+ evidence: {
2422
+ ...(error?.code ? { systemCode: error.code } : {}),
2423
+ reason: error.message,
2424
+ },
2425
+ supportedFixes: [
2426
+ 'choose a writable regular-file destination and retry',
2427
+ ],
2428
+ }),
2429
+ ]
2430
+ reportMigrationFailure({
2431
+ ...migration,
2432
+ migrationDiagnostics: [
2433
+ ...migration.migrationDiagnostics,
2434
+ ...migrationDiagnostics,
2435
+ ],
2436
+ })
2437
+ } finally {
2438
+ try {
2439
+ fs.rmSync(stagingDirectory, { recursive: true, force: true })
2440
+ } catch (error) {
2441
+ console.error(
2442
+ `Warning: could not remove workflow migration staging directory "${stagingDirectory}": ${error.message}`,
2443
+ )
2444
+ }
2445
+ }
2446
+ }
2447
+
2448
+ function commandValidate(args) {
2449
+ const qualityArgs = extractQualityArgs(args)
2450
+ const repoArgs = extractRepoRootArgs(qualityArgs.rest)
2451
+ args = repoArgs.rest
2452
+ const quality = qualityArgs.quality
2453
+ const repoRoot = repoArgs.repoRoot
2454
+ const knownOptions = new Set(['--json', '--layout-json'])
2455
+ const unknown = args.filter(
2456
+ arg => arg.startsWith('--') && !knownOptions.has(arg),
2457
+ )
2458
+ if (unknown.length)
2459
+ rejectCliArgument(`Unknown validate option "${unknown[0]}".`, {
2460
+ code: 'cli/unknown-option',
2461
+ subject: { option: unknown[0] },
2462
+ supportedFixes: ['remove the unknown option and retry'],
2463
+ })
2464
+ const json = args.includes('--json')
2465
+ const layoutJson = args.includes('--layout-json')
2466
+ const rest = args.filter(arg => !knownOptions.has(arg))
2467
+ const [type, input] = rest
2468
+ if (!type || !input || rest.length !== 2)
2469
+ rejectCliArgument(usage(), {
2470
+ code: 'cli/usage',
2471
+ supportedFixes: ['use: archify validate <type> <input.json> [options]'],
2472
+ })
2473
+ assertEvidenceType(type, repoRoot)
2474
+ const renderer = rendererPath(type)
2475
+
2476
+ if (layoutJson && !['architecture', 'workflow'].includes(type)) {
2477
+ rejectCliArgument(
2478
+ '--layout-json is currently supported for architecture and workflow diagrams only.',
2479
+ {
2480
+ code: 'cli/unsupported-option',
2481
+ subject: { option: '--layout-json', type },
2482
+ supportedFixes: [
2483
+ 'remove --layout-json or use an architecture or workflow diagram',
2484
+ ],
2485
+ },
2486
+ )
2487
+ }
2488
+
2489
+ if (layoutJson) {
2490
+ // Layout mode emits JSON without writing HTML; keep its unused target typed.
2491
+ const layoutOutput = path.join(
2492
+ os.tmpdir(),
2493
+ `archify-layout-${process.pid}-${type}.html`,
2494
+ )
2495
+ const result = runNode([renderer, input, layoutOutput, '--layout-json'], {
2496
+ stdio: 'pipe',
2497
+ env: rendererEnv(quality, repoRoot, true),
2498
+ })
2499
+ if (result.status !== 0) {
2500
+ try {
2501
+ const receipt = JSON.parse(result.stdout)
2502
+ if (receipt?.contract && Array.isArray(receipt.diagnostics)) {
2503
+ process.stdout.write(`${JSON.stringify(receipt, null, 2)}\n`)
2504
+ process.exitCode = result.status ?? 1
2505
+ return
2506
+ }
2507
+ } catch {
2508
+ // Fall through to the renderer failure contract when no compiler
2509
+ // receipt was produced (for example, input JSON could not be read).
2510
+ }
2511
+ const failure = rendererFailure(result)
2512
+ reportValidateFailure({
2513
+ json,
2514
+ stage: failure.diagnostics.some(entry =>
2515
+ entry.code.startsWith('input/'),
2516
+ )
2517
+ ? 'input'
2518
+ : 'render',
2519
+ type,
2520
+ input: path.resolve(input),
2521
+ error: failure.error,
2522
+ diagnostics: failure.diagnostics,
2523
+ status: result.status ?? 1,
2524
+ })
2525
+ return
2526
+ }
2527
+ process.stdout.write(result.stdout)
2528
+ return
2529
+ }
2530
+
2531
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-validate-'))
2532
+ const out = path.join(tmp, `${type}.html`)
2533
+ let exitCode = 0
2534
+
2535
+ try {
2536
+ const render = runNode([renderer, input, out], {
2537
+ stdio: 'pipe',
2538
+ env: rendererEnv(quality, repoRoot, true),
2539
+ })
2540
+ if (render.status !== 0) {
2541
+ const failure = rendererFailure(render)
2542
+ reportValidateFailure({
2543
+ json,
2544
+ stage: failure.diagnostics.some(entry =>
2545
+ entry.code.startsWith('input/'),
2546
+ )
2547
+ ? 'input'
2548
+ : 'render',
2549
+ type,
2550
+ input: path.resolve(input),
2551
+ error: failure.error,
2552
+ diagnostics: failure.diagnostics,
2553
+ status: render.status ?? 1,
2554
+ })
2555
+ exitCode = render.status ?? 1
2556
+ } else {
2557
+ const check = runNode(
2558
+ [path.join(skillRoot, 'scripts/check-render-output.mjs'), out],
2559
+ { stdio: 'pipe' },
2560
+ )
2561
+ if (check.status !== 0) {
2562
+ let checker
2563
+ try {
2564
+ checker = JSON.parse(check.stdout)
2565
+ checker.file = path.resolve(input)
2566
+ } catch {
2567
+ checker = {
2568
+ ok: false,
2569
+ diagnostic: 'Artifact checker failed without a parseable receipt.',
2570
+ }
2571
+ }
2572
+ reportValidateFailure({
2573
+ json,
2574
+ stage: 'check',
2575
+ type,
2576
+ input: path.resolve(input),
2577
+ error: 'Final artifact check failed.',
2578
+ diagnostics: checkerDiagnostics(checker),
2579
+ checker,
2580
+ status: check.status ?? 1,
2581
+ })
2582
+ exitCode = check.status ?? 1
2583
+ } else {
2584
+ const result = JSON.parse(check.stdout)
2585
+ const engineeringProfile = engineeringProfileFromArtifact(
2586
+ fs.readFileSync(out),
2587
+ )
2588
+ if (json) {
2589
+ console.log(
2590
+ JSON.stringify(
2591
+ {
2592
+ schemaVersion: 1,
2593
+ ok: true,
2594
+ command: 'validate',
2595
+ type,
2596
+ input: path.resolve(input),
2597
+ checks: result.checks,
2598
+ composition: result.composition,
2599
+ ...(engineeringProfile ? { engineeringProfile } : {}),
2600
+ },
2601
+ null,
2602
+ 2,
2603
+ ),
2604
+ )
2605
+ } else {
2606
+ const engineering = engineeringProfile
2607
+ ? `; engineering ${engineeringProfile}: pass`
2608
+ : ''
2609
+ console.log(
2610
+ `ok ${type} ${path.resolve(input)} (${result.checks.length} artifact checks; composition ${result.composition.profile}: ${result.composition.summary.errors} errors, ${result.composition.summary.warnings} warnings${engineering})`,
2611
+ )
2612
+ }
2613
+ }
2614
+ }
2615
+ } finally {
2616
+ fs.rmSync(tmp, { recursive: true, force: true })
2617
+ }
2618
+
2619
+ if (exitCode !== 0) process.exitCode = exitCode
2620
+ }
2621
+
2622
+ const [command, ...args] = process.argv.slice(2)
2623
+
2624
+ try {
2625
+ switch (command) {
2626
+ case undefined:
2627
+ case '-h':
2628
+ case '--help':
2629
+ case 'help':
2630
+ console.log(usage())
2631
+ break
2632
+ case 'render':
2633
+ commandRender(args)
2634
+ break
2635
+ case 'compare':
2636
+ await commandCompare(args)
2637
+ break
2638
+ case 'deliver':
2639
+ await commandDeliver(args)
2640
+ break
2641
+ case 'preview':
2642
+ await commandPreview(args)
2643
+ break
2644
+ case 'validate':
2645
+ commandValidate(args)
2646
+ break
2647
+ case 'migrate':
2648
+ await commandMigrate(args)
2649
+ break
2650
+ case 'inspect':
2651
+ if (args[0] !== 'architecture') {
2652
+ fail('inspect is currently supported for architecture diagrams only.')
2653
+ }
2654
+ commandValidate([...args, '--layout-json'])
2655
+ break
2656
+ case 'check':
2657
+ commandCheck(args)
2658
+ break
2659
+ case 'visual-check':
2660
+ await commandVisualCheck(args)
2661
+ break
2662
+ case 'guide':
2663
+ await commandGuide(args)
2664
+ break
2665
+ case 'brands':
2666
+ await commandBrands(args)
2667
+ break
2668
+ case 'examples':
2669
+ commandExamples()
2670
+ break
2671
+ case 'doctor':
2672
+ await commandDoctor()
2673
+ break
2674
+ case 'demo':
2675
+ commandDemo(args)
2676
+ break
2677
+ default:
2678
+ fail(`Unknown command "${command}".\n\n${usage()}`)
2679
+ }
2680
+ } catch (error) {
2681
+ if (!error.archifyArgument) throw error
2682
+ if (['validate', 'deliver'].includes(command) && args.includes('--json')) {
2683
+ reportArtifactArgumentFailure(command, error)
2684
+ } else {
2685
+ fail(error.message)
2686
+ }
2687
+ }