import { parseRepositoryRemote } from '../renderers/shared/repository-location.mjs'; const COMPARATOR_VERSION = 1; const CANONICAL_VERSION = 1; export class ArchitectureDeltaError extends Error { constructor(code, message, details = {}) { super(message); this.name = 'ArchitectureDeltaError'; this.code = code; this.details = details; } } const codepointOrder = (left, right) => (left < right ? -1 : left > right ? 1 : 0); const sorted = (values) => [...values].sort((left, right) => codepointOrder(String(left), String(right))); function canonical(value) { if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; if (value && typeof value === 'object') { return `{${Object.keys(value).sort(codepointOrder).map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`; } return JSON.stringify(value); } const equal = (left, right) => canonical(left) === canonical(right); function sortedObjects(values) { return [...values].sort((left, right) => codepointOrder(canonical(left), canonical(right))); } function sortedBy(values, keyFor) { return [...values].sort((left, right) => codepointOrder(String(keyFor(left)), String(keyFor(right)))); } function normalizeRepository(repository) { if (!repository) return undefined; const location = parseRepositoryRemote(repository.url, { authored: true }); const url = location?.url || String(repository.url || ''); return { url: location?.provider === 'github' ? url.toLowerCase() : url, revision: String(repository.revision || '').toLowerCase(), ...(repository.provider !== undefined ? { provider: repository.provider } : {}), ...(repository.link_mode !== undefined ? { link_mode: repository.link_mode } : {}), }; } const PROVENANCE_FIELDS = ['url', 'revision', 'provider', 'link_mode']; function provenanceSide(repository) { if (!repository) return null; return { revision: repository.revision, ...(repository.provider !== undefined ? { provider: repository.provider } : {}), ...(repository.link_mode !== undefined ? { linkMode: repository.link_mode } : {}), }; } function provenanceChange(base, head) { if (equal(base, head)) return undefined; const changedFields = !base || !head ? ['/repository'] : PROVENANCE_FIELDS .filter((field) => !equal(base[field], head[field])) .map((field) => `/${field}`) .sort(codepointOrder); return { changedFields, base: provenanceSide(base), head: provenanceSide(head), }; } function normalizeComponent(component) { return { ...component, ...(Array.isArray(component.sources) ? { sources: sortedObjects(component.sources) } : {}), }; } function normalizeBoundary(boundary) { return { ...boundary, wraps: sorted(boundary.wraps || []) }; } export function canonicalArchitecture(diagram) { const meta = { ...(diagram.meta || {}) }; delete meta.output; if (meta.repository) meta.repository = normalizeRepository(meta.repository); return { schema_version: diagram.schema_version, diagram_type: diagram.diagram_type, meta, ...(diagram.layout ? { layout: diagram.layout } : {}), components: sortedBy((diagram.components || []).map(normalizeComponent), (component) => component.id), boundaries: sortedBy((diagram.boundaries || []).map(normalizeBoundary), boundaryKey), connections: sortedBy(diagram.connections || [], (connection) => connection.id || ''), ...(diagram.cards ? { cards: diagram.cards } : {}), }; } export function canonicalArchitectureJson(diagram) { return canonical(canonicalArchitecture(diagram)); } function fail(code, message, details) { throw new ArchitectureDeltaError(code, message, details); } function requireComparableShape(diagram, side) { if (diagram?.schema_version !== 1) { fail('delta/schema-version-mismatch', `${side} must use schema_version 1.`, { side, path: '/schema_version', actual: diagram?.schema_version }); } if (diagram?.diagram_type !== 'architecture') { fail('delta/type-mismatch', `${side} must use diagram_type architecture.`, { side, path: '/diagram_type', actual: diagram?.diagram_type }); } } function stableIndex(items, collection, side, missingCode = 'delta/stable-id-required') { const index = new Map(); const missing = []; const duplicates = []; (items || []).forEach((item, itemIndex) => { if (!item?.id) missing.push(`/${collection}/${itemIndex}/id`); else if (index.has(item.id)) duplicates.push(item.id); else index.set(item.id, item); }); if (missing.length) { fail(missingCode, `${side} ${collection} require authored stable ids for comparison.`, { side, paths: sorted(missing), supportedFixes: [`add a unique id to every ${collection} item`], }); } if (duplicates.length) { fail('delta/duplicate-stable-id', `${side} ${collection} contain duplicate ids.`, { side, collection, ids: sorted(new Set(duplicates)), supportedFixes: [`make every ${collection} id unique`], }); } return index; } const boundaryKey = (boundary) => `${boundary.kind}\u001f${boundary.label}`; function boundaryIndex(boundaries, side) { const index = new Map(); const ambiguous = []; for (const boundary of boundaries || []) { const key = boundaryKey(boundary); if (index.has(key)) ambiguous.push(`${boundary.kind}:${boundary.label}`); else index.set(key, boundary); } if (ambiguous.length) { fail('delta/boundary-key-ambiguous', `${side} boundary kind + label keys must be unique.`, { side, boundaries: sorted(new Set(ambiguous)), supportedFixes: ['rename one duplicate boundary or add stable boundary ids in a future schema version'], }); } return index; } function normalizedField(item, field) { const value = item?.[field]; if (field === 'sources' && Array.isArray(value)) return sortedObjects(value); if (field === 'wraps' && Array.isArray(value)) return sorted(value); return value; } function fieldChanges(before, after, groups) { const classifications = []; const changedFields = []; for (const [classification, fields] of Object.entries(groups)) { const changed = fields.filter((field) => !equal(normalizedField(before, field), normalizedField(after, field))); if (changed.length) classifications.push(classification); changedFields.push(...changed.map((field) => `/${field}`)); } return { classifications: sorted(classifications), changedFields: sorted(changedFields) }; } const COMPONENT_FIELDS = { semantic: ['type', 'label', 'sublabel', 'tag', 'brand', 'icon'], evidence: ['sources'], geometry: ['row', 'col', 'pos', 'size'], }; const CONNECTION_FIELDS = { topology: ['from', 'to'], semantic: ['label', 'variant'], geometry: ['fromSide', 'toSide', 'route', 'via', 'labelAt', 'labelDx', 'labelDy', 'labelSegment', 'width'], }; const BOUNDARY_FIELDS = { scope: ['wraps'], geometry: ['pad'] }; function statusFor(classifications, kind) { if (classifications.some((value) => ['topology', 'semantic', 'scope'].includes(value))) return 'changed'; if (classifications.includes('evidence')) return 'evidence-changed'; if (classifications.includes('geometry')) return kind === 'connection' ? 'rerouted' : kind === 'component' ? 'moved' : 'geometry-changed'; return 'same'; } function compareEntities(baseIndex, headIndex, kind, groups, describe) { const changes = []; const identityClassification = kind === 'connection' ? 'topology' : kind === 'boundary' ? 'scope' : 'semantic'; for (const id of sorted(new Set([...baseIndex.keys(), ...headIndex.keys()]))) { const base = baseIndex.get(id); const head = headIndex.get(id); if (!base) changes.push({ ...describe(id, undefined, head), status: 'added', classifications: [identityClassification], changedFields: [] }); else if (!head) changes.push({ ...describe(id, base, undefined), status: 'removed', classifications: [identityClassification], changedFields: [] }); else { const fields = fieldChanges(base, head, groups); const status = statusFor(fields.classifications, kind); if (status !== 'same') changes.push({ ...describe(id, base, head), status, ...fields }); } } return changes; } function summaryFor(changes, shape) { const summary = Object.fromEntries(shape.map((key) => [key, 0])); for (const change of changes) { const key = change.status.replace(/-([a-z])/g, (_all, letter) => letter.toUpperCase()); if (Object.hasOwn(summary, key)) summary[key] += 1; } return summary; } function presentationChanged(base, head) { const basePresentation = { title: base.meta?.title, locale: base.meta?.locale, subtitle: base.meta?.subtitle, animation: base.meta?.animation, visual_preset: base.meta?.visual_preset, quality_profile: base.meta?.quality_profile, engineering_profile: base.meta?.engineering_profile, legend: base.meta?.legend, viewBox: base.meta?.viewBox, layout: base.layout, cards: base.cards, }; const headPresentation = { title: head.meta?.title, locale: head.meta?.locale, subtitle: head.meta?.subtitle, animation: head.meta?.animation, visual_preset: head.meta?.visual_preset, quality_profile: head.meta?.quality_profile, engineering_profile: head.meta?.engineering_profile, legend: head.meta?.legend, viewBox: head.meta?.viewBox, layout: head.layout, cards: head.cards, }; return !equal(basePresentation, headPresentation); } export function compareArchitecture(base, head, evidence = {}) { requireComparableShape(base, 'base'); requireComparableShape(head, 'head'); const baseComponents = stableIndex(base.components, 'components', 'base'); const headComponents = stableIndex(head.components, 'components', 'head'); const shared = sorted([...baseComponents.keys()].filter((id) => headComponents.has(id))); if (!shared.length) { fail('delta/no-shared-component-id', 'The snapshots share no component id, so Archify cannot prove that they describe the same system.', { supportedFixes: ['preserve at least one authored component id across snapshots'], }); } const baseConnections = stableIndex(base.connections, 'connections', 'base', 'delta/relationship-id-required'); const headConnections = stableIndex(head.connections, 'connections', 'head', 'delta/relationship-id-required'); const baseBoundaries = boundaryIndex(base.boundaries, 'base'); const headBoundaries = boundaryIndex(head.boundaries, 'head'); const baseRepository = normalizeRepository(base.meta?.repository); const headRepository = normalizeRepository(head.meta?.repository); const identity = (repository) => parseRepositoryRemote(repository.url, { authored: true })?.identity || repository.url; if (baseRepository && headRepository && identity(baseRepository) !== identity(headRepository)) { fail('delta/repository-mismatch', 'The snapshots name different repositories.', { baseRepository: baseRepository.url, headRepository: headRepository.url, supportedFixes: ['compare snapshots from the same repository or remove repository evidence from both inputs'], }); } const proofLevel = baseRepository && headRepository && evidence.baseVerified && evidence.headVerified && /^[a-f0-9]{40}$/.test(baseRepository.revision) && /^[a-f0-9]{40}$/.test(headRepository.revision) ? 'revision-pinned' : 'authored'; const components = compareEntities(baseComponents, headComponents, 'component', COMPONENT_FIELDS, (id, before, after) => ({ id, baseLabel: before?.label, headLabel: after?.label, })); const connections = compareEntities(baseConnections, headConnections, 'connection', CONNECTION_FIELDS, (id, before, after) => ({ id, ...(before ? { base: { from: before.from, to: before.to, label: before.label || '' } } : {}), ...(after ? { head: { from: after.from, to: after.to, label: after.label || '' } } : {}), })); const boundaries = compareEntities(baseBoundaries, headBoundaries, 'boundary', BOUNDARY_FIELDS, (_key, before, after) => ({ key: `${(after || before).kind}:${(after || before).label}`, kind: (after || before).kind, label: (after || before).label, })); const provenance = provenanceChange(baseRepository, headRepository); const provenanceChanged = Boolean(provenance); return { schemaVersion: 1, ok: true, command: 'compare', type: 'architecture', comparatorVersion: COMPARATOR_VERSION, canonicalVersion: CANONICAL_VERSION, completeness: 'complete', proofLevel, base: { title: base.meta?.title || '', ...(evidence.baseRawSha256 ? { rawSha256: evidence.baseRawSha256 } : {}), ...(evidence.baseSemanticSha256 ? { semanticSha256: evidence.baseSemanticSha256 } : {}), ...(Number.isInteger(evidence.baseBytes) ? { bytes: evidence.baseBytes } : {}), ...(baseRepository?.revision ? { revision: baseRepository.revision } : {}), }, head: { title: head.meta?.title || '', ...(evidence.headRawSha256 ? { rawSha256: evidence.headRawSha256 } : {}), ...(evidence.headSemanticSha256 ? { semanticSha256: evidence.headSemanticSha256 } : {}), ...(Number.isInteger(evidence.headBytes) ? { bytes: evidence.headBytes } : {}), ...(headRepository?.revision ? { revision: headRepository.revision } : {}), }, summary: { components: summaryFor(components, ['added', 'changed', 'evidenceChanged', 'removed', 'moved']), connections: summaryFor(connections, ['added', 'changed', 'removed', 'rerouted']), boundaries: summaryFor(boundaries, ['added', 'changed', 'removed', 'geometryChanged']), presentationChanged: presentationChanged(base, head), provenanceChanged, }, changes: { components, connections, boundaries }, ...(provenance ? { provenance } : {}), identity: { components: 'components[].id', connections: 'connections[].id (required)', boundaries: 'boundaries[].kind + boundaries[].label (derived)', }, view: { visualPreset: head.meta?.visual_preset || 'classic' }, limitations: [ 'Authored Architecture IR only; no runtime impact, causality, risk, or mergeability is inferred.', 'Boundary identity is conservatively derived from kind + label.', ], }; } function esc(value) { return String(value ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); } const safeJson = (value) => JSON.stringify(value, null, 2).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026'); export function extractArchitectureSvg(html) { const match = html.match(//); if (!match) fail('delta/svg-missing', 'A validated Architecture artifact did not contain its primary SVG.'); return match[0]; } export function extractArtifactCss(html) { const match = html.match(/Repository provenance changed${esc(provenanceDescription)}` : ''; const rowHtml = rows.length ? rows.map((row, index) => { const label = row.headLabel || row.baseLabel || row.head?.label || row.base?.label || row.label || row.id; const targetSignature = expectedReviewTargetSignature(row); return `
  • `; }).join('\n') : provenanceChanged ? '
  • No component, relationship, or boundary changes; repository provenance changed.
  • ' : '
  • No authored architecture changes.
  • '; const baseView = baseHtml ? `` : baseSvg; const headView = headHtml ? `` : headSvg; const html = ` ${esc(receipt.head.title)} Architecture Delta

    ARCHITECTURE DELTA · ${proof}

    See what changed
    before you merge.

    ${esc(receipt.base.title)} → ${esc(receipt.head.title)}

    ${total(receipt.summary, 'added')}ADDED
    ${total(receipt.summary, 'removed')}REMOVED
    ${changed}CHANGED
    ${provenanceNotice}
    + ADD− DEL~ MOD↔ MOVE
    ${deltaSvg}
    Exact authored changes · ${rows.length}
    `; return html.replace(/[ \t]+$/gm, ''); } export function validateArchitectureDeltaHtml(html, receipt) { const failures = []; const rows = architectureDeltaChangeRows(receipt); const deltaMarkup = html.match(/
    ([\s\S]*?)<\/section>/)?.[1] || ''; const svgTags = [...deltaMarkup.matchAll(/<\/?svg\b[^>]*>/g)]; let svgDepth = 0; let svgRoots = 0; let rootStart = -1; let rootEnd = -1; let svgBalanced = true; for (const match of svgTags) { if (match[0].startsWith(' (html.match(new RegExp(`
    0; const provenanceDescription = provenanceChanged && (legacyProvenance || validProvenanceFields) ? provenanceDetail(receipt.provenance) : ''; const provenanceFieldLabel = legacyProvenance ? 'repository metadata' : validProvenanceFields ? provenanceFields.join(', ') : ''; if (provenanceNotices !== (provenanceChanged ? 1 : 0) || (!provenanceChanged && !legacyProvenance) || (provenanceChanged && !legacyProvenance && !validProvenanceFields) || (provenanceChanged && !html.includes(`data-provenance-fields="${esc(provenanceFieldLabel)}"`)) || (provenanceChanged && !html.includes(`${esc(provenanceDescription)}`))) { failures.push('provenance change notice does not match the receipt'); } if (provenanceChanged && rows.length === 0 && html.includes('No authored architecture changes.')) failures.push('provenance-only delta claims no authored changes'); if (!html.includes('aria-label="Authored change review"')) failures.push('missing exact-ID change navigator'); if ((html.match(/class="change-row"/g) || []).length !== rows.length) failures.push('change navigator row count does not match the receipt'); if (!html.includes('id="export-svg"') || !html.includes('id="share-card"') || !html.includes('window.Archify.deltaExport = { canonicalSvg: canonicalDeltaSvg, shareCard')) { failures.push('missing canonical Delta SVG or Share Card export contract'); } for (const [index, row] of rows.entries()) { const safeKey = esc(row.key).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const rowMatches = [...html.matchAll(new RegExp(`