astro-archify 0.3.4

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 (30) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/astro-archify-integration.d.ts +100 -0
  4. package/astro-archify-integration.js +0 -0
  5. package/package.json +64 -0
  6. package/vendor/archify/LICENSE +22 -0
  7. package/vendor/archify/NOTICE.md +48 -0
  8. package/vendor/archify/assets/template.html +14787 -0
  9. package/vendor/archify/renderers/architecture/grid.mjs +62 -0
  10. package/vendor/archify/renderers/architecture/render-architecture.mjs +1089 -0
  11. package/vendor/archify/renderers/dataflow/render-dataflow.mjs +482 -0
  12. package/vendor/archify/renderers/lifecycle/render-lifecycle.mjs +570 -0
  13. package/vendor/archify/renderers/sequence/render-sequence.mjs +468 -0
  14. package/vendor/archify/renderers/shared/brand-marks.mjs +563 -0
  15. package/vendor/archify/renderers/shared/cli.mjs +220 -0
  16. package/vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
  17. package/vendor/archify/renderers/shared/diagnostics.mjs +116 -0
  18. package/vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
  19. package/vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  20. package/vendor/archify/renderers/shared/generated-validators.mjs +13 -0
  21. package/vendor/archify/renderers/shared/geometry.mjs +1334 -0
  22. package/vendor/archify/renderers/shared/i18n.mjs +594 -0
  23. package/vendor/archify/renderers/shared/layout-report.mjs +40 -0
  24. package/vendor/archify/renderers/shared/legend.mjs +217 -0
  25. package/vendor/archify/renderers/shared/output-path.mjs +321 -0
  26. package/vendor/archify/renderers/shared/repository-evidence.mjs +235 -0
  27. package/vendor/archify/renderers/shared/text-fit.mjs +49 -0
  28. package/vendor/archify/renderers/shared/utils.mjs +232 -0
  29. package/vendor/archify/renderers/shared/validator.mjs +86 -0
  30. package/vendor/archify/renderers/workflow/render-workflow.mjs +749 -0
@@ -0,0 +1,220 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { applyTemplate, renderCards, esc } from './utils.mjs';
4
+ import { validateSchema } from './validator.mjs';
5
+ import { verifyRepositoryEvidence } from './repository-evidence.mjs';
6
+ import { installRendererDiagnosticBoundary, throwDiagnosticProblems } from './diagnostics.mjs';
7
+ import { validateEngineeringProfile } from './engineering-profiles.mjs';
8
+ import { resolveOutputPath } from './output-path.mjs';
9
+ import { prepareDiagramBrandMarks } from './brand-marks.mjs';
10
+ import { resolveLocale, translateMessage } from './i18n.mjs';
11
+
12
+ installRendererDiagnosticBoundary();
13
+
14
+ const outputPathGuards = new Map();
15
+
16
+ // Common CLI head: node render-<type>.mjs [input.json] [output.html]
17
+ // Keep this synchronous because callers also use it to establish the guarded
18
+ // output path before testing a last-moment filesystem alias change.
19
+ export function loadDiagram({ rendererDir, diagramType, defaultExample, argv = process.argv }) {
20
+ const skillRoot = path.resolve(rendererDir, '../..');
21
+ const inputPath = path.resolve(argv[2] || path.join(skillRoot, 'examples', defaultExample));
22
+ const diagram = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
23
+ validateSchema(diagramType, diagram);
24
+ validateGuidedViews(diagramType, diagram);
25
+ validateRelationshipIds(diagramType, diagram);
26
+ validateEngineeringProfile(diagramType, diagram);
27
+ const sourceEvidence = verifyRepositoryEvidence(diagramType, diagram, process.env.ARCHIFY_REPO_ROOT);
28
+ const template = fs.readFileSync(path.join(skillRoot, 'assets/template.html'), 'utf8');
29
+ // Optional chaining: in degraded mode (no ajv) malformed input must still
30
+ // reach the renderer's friendly layout checks instead of crashing here.
31
+ const outputRequest = {
32
+ requestedOutput: argv[3],
33
+ authoredOutput: diagram.meta?.output,
34
+ defaultOutput: `${diagramType}.html`,
35
+ inputPaths: [inputPath],
36
+ cwd: process.cwd(),
37
+ };
38
+ const { outputPath: outPath } = resolveOutputPath(outputRequest);
39
+ outputPathGuards.set(outPath, outputRequest);
40
+ return { diagram, template, outPath, sourceEvidence };
41
+ }
42
+
43
+ // Brand URL capture is the only asynchronous authoring step. Typed renderers
44
+ // opt into it through this wrapper without changing loadDiagram's long-lived
45
+ // synchronous safety contract.
46
+ export async function loadDiagramWithBrandMarks(options) {
47
+ const loaded = loadDiagram(options);
48
+ await prepareDiagramBrandMarks(options.diagramType, loaded.diagram);
49
+ return loaded;
50
+ }
51
+
52
+ const START_TYPES = new Set(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']);
53
+
54
+ // Common CLI tail: fill the template and write the standalone HTML file.
55
+ export function writeDiagram({ outPath, template, diagramType, meta, svg, cards, sourceEvidence = null }) {
56
+ if (!START_TYPES.has(diagramType)) throw new Error(`writeDiagram: unknown diagram type ${JSON.stringify(diagramType)}`);
57
+ const outputGuard = outputPathGuards.get(outPath);
58
+ if (outputGuard) resolveOutputPath(outputGuard);
59
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
60
+ fs.writeFileSync(outPath, applyTemplate(template, {
61
+ title: meta.title,
62
+ subtitle: meta.subtitle,
63
+ svg,
64
+ cards: renderCards(cards),
65
+ locale: meta.locale,
66
+ visualPreset: meta.visual_preset || 'classic',
67
+ guidedViews: meta.views || [],
68
+ sourceEvidence,
69
+ }));
70
+ outputPathGuards.delete(outPath);
71
+ console.log(outPath);
72
+ }
73
+
74
+ const SEMANTIC_COLLECTIONS = {
75
+ architecture: 'components',
76
+ workflow: 'nodes',
77
+ sequence: 'participants',
78
+ dataflow: 'nodes',
79
+ lifecycle: 'states',
80
+ };
81
+
82
+ const RELATIONSHIP_COLLECTIONS = {
83
+ architecture: 'connections',
84
+ workflow: 'edges',
85
+ sequence: 'messages',
86
+ dataflow: 'flows',
87
+ lifecycle: 'transitions',
88
+ };
89
+
90
+ // Relationship IDs are optional for backwards compatibility, but once an
91
+ // author supplies one it becomes the durable identity used by viewer links.
92
+ // Keep uniqueness enforcement in the shared zero-install path so every typed
93
+ // renderer fails the same way even when development dependencies are absent.
94
+ export function validateRelationshipIds(diagramType, diagram) {
95
+ const collection = RELATIONSHIP_COLLECTIONS[diagramType];
96
+ const relationships = collection && Array.isArray(diagram[collection]) ? diagram[collection] : [];
97
+ const seen = new Set();
98
+ const problems = [];
99
+
100
+ relationships.forEach((relationship, index) => {
101
+ if (relationship.id === undefined || relationship.id === null || relationship.id === '') return;
102
+ if (seen.has(relationship.id)) {
103
+ problems.push(`/${collection}/${index}/id duplicates relationship id ${JSON.stringify(relationship.id)}`);
104
+ }
105
+ seen.add(relationship.id);
106
+ });
107
+
108
+ if (problems.length) {
109
+ throwDiagnosticProblems('Relationship identity validation failed', problems, {
110
+ code: 'relationship/duplicate-id',
111
+ subject: { diagramType, collection },
112
+ });
113
+ }
114
+ }
115
+
116
+ // JSON Schema keeps the view object bounded; this pass checks facts that span
117
+ // collections. Keeping it here makes the same contract apply to all five
118
+ // renderers, including the zero-install standalone-validator path.
119
+ export function validateGuidedViews(diagramType, diagram) {
120
+ const views = diagram.meta?.views;
121
+ if (!Array.isArray(views) || views.length === 0) return;
122
+ const collection = SEMANTIC_COLLECTIONS[diagramType];
123
+ const semanticIds = new Set((diagram[collection] || []).map((item) => item.id));
124
+ const seen = new Set();
125
+ const problems = [];
126
+
127
+ views.forEach((view, index) => {
128
+ if (seen.has(view.id)) problems.push(`/meta/views/${index}/id duplicates view id ${JSON.stringify(view.id)}`);
129
+ seen.add(view.id);
130
+ const seenFocus = new Set();
131
+ (view.focus || []).forEach((id, focusIndex) => {
132
+ if (seenFocus.has(id)) {
133
+ problems.push(`/meta/views/${index}/focus/${focusIndex} duplicates semantic id ${JSON.stringify(id)}`);
134
+ }
135
+ seenFocus.add(id);
136
+ if (!semanticIds.has(id)) {
137
+ problems.push(`/meta/views/${index}/focus/${focusIndex} references unknown semantic id ${JSON.stringify(id)}`);
138
+ }
139
+ });
140
+ });
141
+
142
+ if (problems.length) {
143
+ throwDiagnosticProblems('Guided view validation failed', problems, {
144
+ code: 'guided-view/invalid',
145
+ subject: { diagramType, collection: 'meta.views' },
146
+ });
147
+ }
148
+ }
149
+
150
+ // Accessible name for the generated diagram SVG.
151
+ export function svgRootAttrs(meta, kind) {
152
+ const animation = meta.animation === 'trace' ? ' data-animation="trace"' : '';
153
+ const preset = ` data-preset="${esc(meta.visual_preset || 'classic')}"`;
154
+ const engineeringProfile = meta.engineering_profile
155
+ ? ` data-engineering-profile="${esc(meta.engineering_profile)}"`
156
+ : '';
157
+ const requestedProfile = process.env.ARCHIFY_QUALITY_PROFILE || meta.quality_profile;
158
+ const qualityProfile = requestedProfile === 'showcase' ? 'showcase' : 'standard';
159
+ const advisory = requestedProfile ? '' : ' data-quality-gates="advisory"';
160
+ 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}`;
161
+ }
162
+
163
+ // Keep the accessible name inside the SVG so it survives standalone SVG
164
+ // export and embedding. The fixed IDs are deterministic because an Archify
165
+ // artifact intentionally contains one primary diagram SVG.
166
+ export function svgAccessibleText(meta, kind) {
167
+ const description = meta.subtitle || translateMessage(meta.locale, `diagram.description.${kind}`);
168
+ return ` <title id="archify-diagram-title">${esc(meta.title)}</title>\n <desc id="archify-diagram-description">${esc(description)}</desc>`;
169
+ }
170
+
171
+ export function animateAttr(meta, kind, step) {
172
+ if (meta.animation !== 'trace') return '';
173
+ // Ambient trace must finish inside the fixed six-second WebM capture. The
174
+ // cap affects visual delay only; authored order and semantic identity stay
175
+ // untouched in the JSON, DOM, Story, and relationship contracts.
176
+ const safeStep = Number.isFinite(step) && step >= 0 ? Math.min(12, Math.floor(step)) : 0;
177
+ return ` data-animate="${kind}" style="--step:${safeStep}"`;
178
+ }
179
+
180
+ // Stable semantic hooks for the standalone HTML explorer. IDs already pass
181
+ // the schema's conservative identifier pattern; escape again at the markup
182
+ // boundary so these helpers remain safe if that contract expands later.
183
+ export function focusNodeAttrs(id, label, metadata = {}, locale) {
184
+ const optional = [
185
+ ['data-node-kind', metadata.kind],
186
+ ['data-node-sublabel', metadata.sublabel],
187
+ ['data-node-tag', metadata.tag],
188
+ ['data-node-context', metadata.context],
189
+ ['data-node-brand', metadata.brand],
190
+ ['data-node-brand-id', metadata.brandId],
191
+ ['data-node-brand-status', metadata.brandStatus],
192
+ ['data-node-brand-source', metadata.brandSource],
193
+ ].filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '')
194
+ .map(([name, value]) => ` ${name}="${esc(String(value))}"`)
195
+ .join('');
196
+ const detail = [metadata.sublabel, metadata.context, metadata.brand]
197
+ .filter((value) => value !== undefined && value !== null && String(value).trim() !== '')
198
+ .join(', ');
199
+ const aria = detail
200
+ ? translateMessage(locale, 'node.focus.detail', { label, detail })
201
+ : translateMessage(locale, 'node.focus', { label });
202
+ 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}`;
203
+ }
204
+
205
+ // Native SVG titles preserve a compact details-on-demand fallback when the
206
+ // canonical SVG is embedded inline outside the full Archify viewer.
207
+ export function focusNodeTitle(label, metadata = {}) {
208
+ const parts = [label, metadata.sublabel, metadata.context, metadata.tag, metadata.brand]
209
+ .filter((value) => value !== undefined && value !== null && String(value).trim() !== '');
210
+ return `<title>${esc(parts.join(' · '))}</title>`;
211
+ }
212
+
213
+ export function focusEdgeAttrs(from, to, label, key, id) {
214
+ const named = label ? ` data-edge-label="${esc(label)}"` : '';
215
+ const keyed = key !== undefined && key !== null ? ` data-edge-key="${esc(String(key))}"` : '';
216
+ const identified = id !== undefined && id !== null && String(id).trim() !== ''
217
+ ? ` data-edge-id="${esc(String(id))}"`
218
+ : '';
219
+ return `data-edge-from="${esc(from)}" data-edge-to="${esc(to)}"${named}${keyed}${identified}`;
220
+ }
@@ -0,0 +1,26 @@
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
+
7
+ export function projectedNodeTextPx(sourceFontPx, viewBoxWidth, diagramWidth = DESKTOP_READER_DIAGRAM_WIDTH) {
8
+ if (![sourceFontPx, viewBoxWidth, diagramWidth].every(Number.isFinite) || viewBoxWidth <= 0 || diagramWidth <= 0) {
9
+ return Number.NaN;
10
+ }
11
+ return sourceFontPx * Math.min(1, diagramWidth / viewBoxWidth);
12
+ }
13
+
14
+ export function minimumReadableSourceTextPx(
15
+ viewBoxWidth,
16
+ diagramWidth = DESKTOP_READER_DIAGRAM_WIDTH,
17
+ minimumProjectedPx = MIN_PROJECTED_NODE_TEXT_PX,
18
+ ) {
19
+ if (![viewBoxWidth, diagramWidth, minimumProjectedPx].every(Number.isFinite)
20
+ || viewBoxWidth <= 0
21
+ || diagramWidth <= 0
22
+ || minimumProjectedPx <= 0) {
23
+ return Number.NaN;
24
+ }
25
+ return minimumProjectedPx / Math.min(1, diagramWidth / viewBoxWidth);
26
+ }
@@ -0,0 +1,116 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const DIAGNOSTIC_MODE = process.env.ARCHIFY_DIAGNOSTIC_FORMAT === 'json';
5
+ const recorded = [];
6
+ const recordedMessages = new Set();
7
+ const boundaryKey = Symbol.for('archify.renderer-diagnostic-boundary');
8
+
9
+ function plainObject(value) {
10
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
11
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
12
+ }
13
+
14
+ function normalizedDiagnostic(diagnostic) {
15
+ const message = String(diagnostic?.message || 'Archify could not classify this failure.').trim();
16
+ return {
17
+ code: String(diagnostic?.code || 'internal/unclassified'),
18
+ severity: diagnostic?.severity === 'warning' ? 'warning' : 'error',
19
+ message,
20
+ subject: plainObject(diagnostic?.subject),
21
+ evidence: plainObject(diagnostic?.evidence),
22
+ supportedFixes: Array.isArray(diagnostic?.supportedFixes)
23
+ ? [...new Set(diagnostic.supportedFixes.map((fix) => String(fix).trim()).filter(Boolean))]
24
+ : [],
25
+ };
26
+ }
27
+
28
+ export function recordDiagnostic(diagnostic) {
29
+ if (!DIAGNOSTIC_MODE) return;
30
+ const normalized = normalizedDiagnostic(diagnostic);
31
+ if (recordedMessages.has(normalized.message)) return;
32
+ recordedMessages.add(normalized.message);
33
+ recorded.push(normalized);
34
+ }
35
+
36
+ export function throwDiagnosticError(message, diagnostics) {
37
+ for (const diagnostic of diagnostics || []) recordDiagnostic(diagnostic);
38
+ const error = new Error(message);
39
+ error.archifyDiagnostics = (diagnostics || []).map(normalizedDiagnostic);
40
+ throw error;
41
+ }
42
+
43
+ export function throwDiagnosticProblems(prefix, problems, { code = 'layout/constraint', subject = {} } = {}) {
44
+ const messages = (problems || []).map((problem) => String(problem));
45
+ for (const message of messages) {
46
+ recordDiagnostic({
47
+ code,
48
+ severity: 'error',
49
+ message,
50
+ subject,
51
+ evidence: {},
52
+ supportedFixes: [],
53
+ });
54
+ }
55
+ throw new Error(`${prefix}:\n- ${messages.join('\n- ')}`);
56
+ }
57
+
58
+ function fallbackDiagnostic(error) {
59
+ const input = process.argv[2] ? path.resolve(process.argv[2]) : undefined;
60
+ if (error instanceof SyntaxError) {
61
+ return normalizedDiagnostic({
62
+ code: 'input/json-parse',
63
+ severity: 'error',
64
+ message: `Input JSON could not be parsed: ${error.message}`,
65
+ subject: { input },
66
+ evidence: { reason: error.message },
67
+ supportedFixes: ['repair the JSON syntax and run validation again'],
68
+ });
69
+ }
70
+ if (error?.code === 'ENOENT' || error?.code === 'EACCES' || error?.code === 'EISDIR') {
71
+ return normalizedDiagnostic({
72
+ code: 'input/read',
73
+ severity: 'error',
74
+ message: `Input could not be read: ${error.message}`,
75
+ subject: { input },
76
+ evidence: { systemCode: error.code, reason: error.message },
77
+ supportedFixes: ['provide one readable JSON input file'],
78
+ });
79
+ }
80
+ return normalizedDiagnostic({
81
+ code: 'internal/unclassified',
82
+ severity: 'error',
83
+ message: error?.message || 'Renderer failed without a diagnostic.',
84
+ subject: { input },
85
+ evidence: { errorName: error?.name || 'Error' },
86
+ supportedFixes: [],
87
+ });
88
+ }
89
+ function rendererFailure(error) {
90
+ const attached = Array.isArray(error?.archifyDiagnostics)
91
+ ? error.archifyDiagnostics.map(normalizedDiagnostic)
92
+ : [];
93
+ const diagnostics = recorded.length ? recorded : (attached.length ? attached : [fallbackDiagnostic(error)]);
94
+ return {
95
+ schemaVersion: 1,
96
+ ok: false,
97
+ source: 'renderer',
98
+ error: error?.message || 'Renderer failed without a diagnostic.',
99
+ diagnostics,
100
+ };
101
+ }
102
+
103
+ export function installRendererDiagnosticBoundary() {
104
+ if (!DIAGNOSTIC_MODE || globalThis[boundaryKey]) return;
105
+ globalThis[boundaryKey] = true;
106
+ process.on('uncaughtException', (error) => {
107
+ const payload = `${JSON.stringify(rendererFailure(error))}\n`;
108
+ try {
109
+ fs.writeSync(process.stderr.fd, payload);
110
+ } catch {
111
+ // The renderer is already failing. Avoid replacing its real error with a
112
+ // secondary stream failure; the parent CLI still has the exit status.
113
+ }
114
+ process.exit(1);
115
+ });
116
+ }
@@ -0,0 +1,157 @@
1
+ import { throwDiagnosticError } from './diagnostics.mjs';
2
+
3
+ const DEPLOYMENT_PROFILE = 'deployment-ownership';
4
+ const DEPLOYMENT_BOUNDARY_KINDS = new Set(['region', 'security-group']);
5
+ const PRIVATE_STATE_TYPES = new Set(['database']);
6
+
7
+ function subject(collection, index, item = {}) {
8
+ return {
9
+ diagramType: 'architecture',
10
+ profile: DEPLOYMENT_PROFILE,
11
+ collection,
12
+ index,
13
+ ...(item.id ? { id: item.id } : {}),
14
+ };
15
+ }
16
+
17
+ function membership(boundaries, componentId, kind) {
18
+ return boundaries
19
+ .map((boundary, index) => ({ boundary, index }))
20
+ .filter(({ boundary }) => boundary.kind === kind && boundary.wraps.includes(componentId));
21
+ }
22
+
23
+ export function deploymentOwnershipDiagnostics(diagram) {
24
+ const components = Array.isArray(diagram.components) ? diagram.components : [];
25
+ const boundaries = (Array.isArray(diagram.boundaries) ? diagram.boundaries : [])
26
+ .map((boundary) => ({ ...boundary, wraps: Array.isArray(boundary.wraps) ? boundary.wraps : [] }));
27
+ const connections = Array.isArray(diagram.connections) ? diagram.connections : [];
28
+ const diagnostics = [];
29
+
30
+ for (const kind of DEPLOYMENT_BOUNDARY_KINDS) {
31
+ const count = boundaries.filter((boundary) => boundary.kind === kind).length;
32
+ if (count > 0) continue;
33
+ diagnostics.push({
34
+ code: 'engineering/deployment-boundary-kind',
35
+ severity: 'error',
36
+ message: `Deployment ownership requires at least one ${kind} boundary.`,
37
+ subject: subject('boundaries', -1),
38
+ evidence: { requiredKind: kind, found: count },
39
+ supportedFixes: [`add one ${kind} boundary with an explicit wraps list`],
40
+ });
41
+ }
42
+
43
+ components.forEach((component, index) => {
44
+ if (component.type === 'external') return;
45
+ if (typeof component.tag !== 'string' || component.tag.trim() === '') {
46
+ diagnostics.push({
47
+ code: 'engineering/deployment-owner-missing',
48
+ severity: 'error',
49
+ message: `Deployment component ${JSON.stringify(component.id)} does not name its owner in tag.`,
50
+ subject: subject('components', index, component),
51
+ evidence: { componentType: component.type, ownerField: 'tag' },
52
+ supportedFixes: [`set /components/${index}/tag to the responsible team or owner`],
53
+ });
54
+ }
55
+
56
+ const regions = membership(boundaries, component.id, 'region');
57
+ if (regions.length === 0) {
58
+ diagnostics.push({
59
+ code: 'engineering/deployment-region-scope',
60
+ severity: 'error',
61
+ message: `Deployment component ${JSON.stringify(component.id)} is not assigned to a region boundary.`,
62
+ subject: subject('components', index, component),
63
+ evidence: { componentType: component.type, regionMemberships: 0 },
64
+ supportedFixes: ['add the component id to the real region boundary wraps list'],
65
+ });
66
+ } else if (regions.length > 1) {
67
+ diagnostics.push({
68
+ code: 'engineering/deployment-region-ambiguous',
69
+ severity: 'error',
70
+ message: `Deployment component ${JSON.stringify(component.id)} belongs to more than one region boundary.`,
71
+ subject: subject('components', index, component),
72
+ evidence: {
73
+ componentType: component.type,
74
+ regions: regions.map(({ boundary, index: boundaryIndex }) => ({ boundaryIndex, label: boundary.label })),
75
+ },
76
+ supportedFixes: ['keep the component id in exactly one real region boundary wraps list'],
77
+ });
78
+ }
79
+
80
+ if (PRIVATE_STATE_TYPES.has(component.type)) {
81
+ const privateScopes = membership(boundaries, component.id, 'security-group');
82
+ if (privateScopes.length === 0) {
83
+ diagnostics.push({
84
+ code: 'engineering/deployment-private-state',
85
+ severity: 'error',
86
+ message: `Stateful component ${JSON.stringify(component.id)} is not assigned to a private security-group boundary.`,
87
+ subject: subject('components', index, component),
88
+ evidence: { componentType: component.type, privateMemberships: 0 },
89
+ supportedFixes: ['add the component id to the real private security-group boundary wraps list'],
90
+ });
91
+ }
92
+ }
93
+ });
94
+
95
+ boundaries.forEach((boundary, index) => {
96
+ if (boundary.kind !== 'security-group') return;
97
+ const members = boundary.wraps.map((id) => ({
98
+ id,
99
+ regions: membership(boundaries, id, 'region').map(({ boundary: region, index: boundaryIndex }) => ({
100
+ boundaryIndex,
101
+ label: region.label,
102
+ })),
103
+ }));
104
+ const regionIndexes = new Set(members.flatMap((member) => member.regions.map((region) => region.boundaryIndex)));
105
+ const consistent = members.length > 0
106
+ && members.every((member) => member.regions.length === 1)
107
+ && regionIndexes.size === 1;
108
+ if (consistent) return;
109
+ diagnostics.push({
110
+ code: 'engineering/deployment-private-region-consistency',
111
+ severity: 'error',
112
+ message: `Private boundary ${JSON.stringify(boundary.label)} must contain components from exactly one shared region.`,
113
+ subject: subject('boundaries', index, boundary),
114
+ evidence: { boundaryKind: boundary.kind, members },
115
+ supportedFixes: ['assign every private-boundary component to exactly one shared region boundary'],
116
+ });
117
+ });
118
+
119
+ connections.forEach((connection, index) => {
120
+ const crossedBoundaries = boundaries
121
+ .map((boundary, boundaryIndex) => ({
122
+ boundaryIndex,
123
+ kind: boundary.kind,
124
+ label: boundary.label,
125
+ fromInside: boundary.wraps.includes(connection.from),
126
+ toInside: boundary.wraps.includes(connection.to),
127
+ }))
128
+ .filter((boundary) => DEPLOYMENT_BOUNDARY_KINDS.has(boundary.kind) && boundary.fromInside !== boundary.toInside);
129
+ if (crossedBoundaries.length === 0 || (typeof connection.label === 'string' && connection.label.trim() !== '')) return;
130
+ diagnostics.push({
131
+ code: 'engineering/deployment-crossing-mechanism',
132
+ severity: 'error',
133
+ message: `Cross-boundary connection ${JSON.stringify(connection.id || `${connection.from}->${connection.to}`)} does not name its mechanism.`,
134
+ subject: subject('connections', index, connection),
135
+ evidence: {
136
+ from: connection.from,
137
+ to: connection.to,
138
+ crossedBoundaries: crossedBoundaries.map(({ boundaryIndex, kind, label }) => ({ boundaryIndex, kind, label })),
139
+ },
140
+ supportedFixes: [`set /connections/${index}/label to the real cross-boundary mechanism`],
141
+ });
142
+ });
143
+
144
+ return diagnostics;
145
+ }
146
+
147
+ export function validateEngineeringProfile(diagramType, diagram) {
148
+ const profile = diagram.meta?.engineering_profile;
149
+ if (!profile) return;
150
+ if (diagramType !== 'architecture' || profile !== DEPLOYMENT_PROFILE) return;
151
+ const diagnostics = deploymentOwnershipDiagnostics(diagram);
152
+ if (!diagnostics.length) return;
153
+ throwDiagnosticError(
154
+ `Engineering profile ${JSON.stringify(profile)} failed:\n${diagnostics.map((entry) => `- ${entry.message}`).join('\n')}`,
155
+ diagnostics,
156
+ );
157
+ }