Public/Compare-IdentityAtlas.ps1

function Compare-IdentityAtlas {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $ReferenceReportPath,

        [Parameter(Mandatory)]
        [string] $DifferenceReportPath,

        [string] $OutputPath
    )

    function Read-AtlasReportJson {
        param([string] $Path)

        $candidate = if ((Get-Item -LiteralPath $Path).PSIsContainer) {
            Join-Path $Path 'data/report.json'
        }
        else {
            $Path
        }

        return Get-Content -Raw -LiteralPath $candidate | ConvertFrom-Json -Depth 30
    }

    $reference = Read-AtlasReportJson -Path $ReferenceReportPath
    $difference = Read-AtlasReportJson -Path $DifferenceReportPath

    $referenceNodes = @{}
    $differenceNodes = @{}
    $referenceEdges = @{}
    $differenceEdges = @{}

    foreach ($node in $reference.nodes) { $referenceNodes[$node.Key] = $node }
    foreach ($node in $difference.nodes) { $differenceNodes[$node.Key] = $node }
    foreach ($edge in $reference.edges) { $referenceEdges[$edge.Key] = $edge }
    foreach ($edge in $difference.edges) { $differenceEdges[$edge.Key] = $edge }

    function Convert-AtlasNodeSummary {
        param([object] $Node)

        [pscustomobject] @{
            key = $Node.Key
            id = $Node.Id
            kind = $Node.Kind
            displayName = $Node.DisplayName
            status = $Node.Status
        }
    }

    function Convert-AtlasEdgeSummary {
        param([object] $Edge, [hashtable] $NodeLookup)

        $from = $NodeLookup[$Edge.From]
        $to = $NodeLookup[$Edge.To]
        [pscustomobject] @{
            key = $Edge.Key
            relationship = $Edge.Relationship
            from = if ($from) { $from.DisplayName } else { $Edge.From }
            fromKind = if ($from) { $from.Kind } else { 'unresolved' }
            to = if ($to) { $to.DisplayName } else { $Edge.To }
            toKind = if ($to) { $to.Kind } else { 'unresolved' }
            state = $Edge.State
        }
    }

    function ConvertTo-AtlasStableNode {
        param([object] $Node)

        [pscustomobject] @{
            id = $Node.Id
            kind = $Node.Kind
            displayName = $Node.DisplayName
            status = $Node.Status
            tags = @($Node.Tags)
            properties = $Node.Properties
            source = $Node.Source
        }
    }

    function ConvertTo-AtlasStableEdge {
        param([object] $Edge)

        [pscustomobject] @{
            from = $Edge.From
            to = $Edge.To
            relationship = $Edge.Relationship
            state = $Edge.State
            source = $Edge.Source
        }
    }

    function ConvertTo-AtlasStableJson {
        param([object] $InputObject)

        return ($InputObject | ConvertTo-Json -Depth 30 -Compress)
    }

    $addedNodes = @($differenceNodes.Keys | Where-Object { -not $referenceNodes.ContainsKey($_) } | ForEach-Object {
        Convert-AtlasNodeSummary -Node $differenceNodes[$_]
    })
    $removedNodes = @($referenceNodes.Keys | Where-Object { -not $differenceNodes.ContainsKey($_) } | ForEach-Object {
        Convert-AtlasNodeSummary -Node $referenceNodes[$_]
    })
    $addedEdges = @($differenceEdges.Keys | Where-Object { -not $referenceEdges.ContainsKey($_) } | ForEach-Object {
        Convert-AtlasEdgeSummary -Edge $differenceEdges[$_] -NodeLookup $differenceNodes
    })
    $removedEdges = @($referenceEdges.Keys | Where-Object { -not $differenceEdges.ContainsKey($_) } | ForEach-Object {
        Convert-AtlasEdgeSummary -Edge $referenceEdges[$_] -NodeLookup $referenceNodes
    })
    $changedNodes = @($differenceNodes.Keys | Where-Object {
        $referenceNodes.ContainsKey($_) -and
        (ConvertTo-AtlasStableJson -InputObject (ConvertTo-AtlasStableNode -Node $referenceNodes[$_])) -ne
            (ConvertTo-AtlasStableJson -InputObject (ConvertTo-AtlasStableNode -Node $differenceNodes[$_]))
    } | ForEach-Object {
        [pscustomobject] @{
            key = $_
            before = Convert-AtlasNodeSummary -Node $referenceNodes[$_]
            after = Convert-AtlasNodeSummary -Node $differenceNodes[$_]
            beforeProperties = $referenceNodes[$_].Properties
            afterProperties = $differenceNodes[$_].Properties
        }
    })
    $changedEdges = @($differenceEdges.Keys | Where-Object {
        $referenceEdges.ContainsKey($_) -and
        (ConvertTo-AtlasStableJson -InputObject (ConvertTo-AtlasStableEdge -Edge $referenceEdges[$_])) -ne
            (ConvertTo-AtlasStableJson -InputObject (ConvertTo-AtlasStableEdge -Edge $differenceEdges[$_]))
    } | ForEach-Object {
        [pscustomobject] @{
            key = $_
            before = Convert-AtlasEdgeSummary -Edge $referenceEdges[$_] -NodeLookup $referenceNodes
            after = Convert-AtlasEdgeSummary -Edge $differenceEdges[$_] -NodeLookup $differenceNodes
        }
    })

    $comparison = [pscustomobject] @{
        generatedAtUtc = ([datetime]::UtcNow.ToString('o'))
        reference = [pscustomobject] @{
            tenant = $reference.manifest.tenant.displayName
            generatedAtUtc = $reference.manifest.generatedAtUtc
            counts = $reference.manifest.counts
        }
        difference = [pscustomobject] @{
            tenant = $difference.manifest.tenant.displayName
            generatedAtUtc = $difference.manifest.generatedAtUtc
            counts = $difference.manifest.counts
        }
        summary = [pscustomobject] @{
            addedNodes = $addedNodes.Count
            removedNodes = $removedNodes.Count
            changedNodes = $changedNodes.Count
            addedEdges = $addedEdges.Count
            removedEdges = $removedEdges.Count
            changedEdges = $changedEdges.Count
        }
        addedNodes = $addedNodes
        removedNodes = $removedNodes
        changedNodes = $changedNodes
        addedEdges = $addedEdges
        removedEdges = $removedEdges
        changedEdges = $changedEdges
    }

    if ($OutputPath) {
        $resolvedOutputPath = [System.IO.Path]::GetFullPath($OutputPath)
        $null = New-Item -ItemType Directory -Path $resolvedOutputPath -Force
        $jsonPath = Join-Path $resolvedOutputPath 'comparison.json'
        $markdownPath = Join-Path $resolvedOutputPath 'comparison.md'
        $htmlPath = Join-Path $resolvedOutputPath 'comparison.html'
        Write-AtlasTextFile -Path $jsonPath -Content ($comparison | ConvertTo-Json -Depth 30)

        $lines = [System.Collections.Generic.List[string]]::new()
        $lines.Add('# Identity Atlas comparison')
        $lines.Add('')
        $lines.Add("Generated at UTC: $($comparison.generatedAtUtc)")
        $lines.Add('')
        $lines.Add('## Summary')
        $lines.Add('')
        $lines.Add("Added objects: $($comparison.summary.addedNodes)")
        $lines.Add("Removed objects: $($comparison.summary.removedNodes)")
        $lines.Add("Changed objects: $($comparison.summary.changedNodes)")
        $lines.Add("Added relationships: $($comparison.summary.addedEdges)")
        $lines.Add("Removed relationships: $($comparison.summary.removedEdges)")
        $lines.Add("Changed relationships: $($comparison.summary.changedEdges)")
        $lines.Add('')
        foreach ($section in @(
            @{ Title = 'Added objects'; Items = $addedNodes; Format = { param($item) "- $($item.displayName) [$($item.kind)]" } }
            @{ Title = 'Removed objects'; Items = $removedNodes; Format = { param($item) "- $($item.displayName) [$($item.kind)]" } }
            @{ Title = 'Changed objects'; Items = $changedNodes; Format = { param($item) "- $($item.after.displayName) [$($item.after.kind)]" } }
            @{ Title = 'Added relationships'; Items = $addedEdges; Format = { param($item) "- $($item.from) $($item.relationship) $($item.to)" } }
            @{ Title = 'Removed relationships'; Items = $removedEdges; Format = { param($item) "- $($item.from) $($item.relationship) $($item.to)" } }
            @{ Title = 'Changed relationships'; Items = $changedEdges; Format = { param($item) "- $($item.after.from) $($item.after.relationship) $($item.after.to)" } }
        )) {
            $lines.Add("## $($section.Title)")
            $lines.Add('')
            if ($section.Items.Count -eq 0) {
                $lines.Add('None.')
            }
            else {
                foreach ($item in $section.Items | Select-Object -First 50) {
                    $lines.Add((& $section.Format $item))
                }
            }
            $lines.Add('')
        }
        Write-AtlasTextFile -Path $markdownPath -Content ($lines -join "`n")

        $comparisonJson = ($comparison | ConvertTo-Json -Depth 30 -Compress)
        $comparisonDataPath = Join-Path $resolvedOutputPath 'comparison-data.js'
        $comparisonAppPath = Join-Path $resolvedOutputPath 'comparison-app.js'
        Write-AtlasTextFile `
            -Path $comparisonDataPath `
            -Content "window.IdentityAtlasComparisonData = $comparisonJson;"

        $comparisonApplication = @'
(function initialiseIdentityAtlasComparison() {
  'use strict';

  const comparison = window.IdentityAtlasComparisonData;
  if (!comparison) {
    throw new Error('Identity Atlas comparison data is unavailable.');
  }

  const make = (tag, className, text) => {
    const element = document.createElement(tag);
    if (className) element.className = className;
    if (text !== undefined) element.textContent = text;
    return element;
  };
  document.getElementById('comparison-range').textContent =
    comparison.reference.tenant + ' compared with ' + comparison.difference.tenant;
  const cards = document.getElementById('summary-cards');
  for (const [label, value, className] of [
    ['Added objects', comparison.summary.addedNodes, 'added'],
    ['Removed objects', comparison.summary.removedNodes, 'removed'],
    ['Changed objects', comparison.summary.changedNodes, 'changed'],
    ['Added relationships', comparison.summary.addedEdges, 'added'],
    ['Removed relationships', comparison.summary.removedEdges, 'removed'],
    ['Changed relationships', comparison.summary.changedEdges, 'changed']
  ]) {
    const card = make('div', 'card');
    card.append(make('span', 'label', label), make('span', 'value ' + className, String(value)));
    cards.append(card);
  }
  const renderEmpty = (container) => container.append(make('div', 'item meta', 'None.'));
  const renderNodes = (id, items, mode) => {
    const container = document.getElementById(id);
    if (!items.length) { renderEmpty(container); return; }
    for (const item of items.slice(0, 100)) {
      const node = item.displayName ? item : item.after;
      const row = make('div', 'item');
      row.append(make('span', mode, node.displayName), make('span', 'meta', node.kind + ' | ' + node.id));
      if (item.beforeProperties || item.afterProperties) {
        row.append(make('pre', null, JSON.stringify({ before: item.beforeProperties, after: item.afterProperties }, null, 2)));
      }
      container.append(row);
    }
  };
  const renderEdges = (id, items, mode) => {
    const container = document.getElementById(id);
    if (!items.length) { renderEmpty(container); return; }
    for (const item of items.slice(0, 100)) {
      const edge = item.relationship ? item : item.after;
      const row = make('div', 'item');
      row.append(make('span', mode, edge.from + ' ' + edge.relationship + ' ' + edge.to), make('span', 'meta', edge.fromKind + ' to ' + edge.toKind));
      if (item.before || item.after) {
        row.append(make('pre', null, JSON.stringify({ before: item.before.state, after: item.after.state }, null, 2)));
      }
      container.append(row);
    }
  };
  renderNodes('added-nodes', comparison.addedNodes, 'added');
  renderNodes('removed-nodes', comparison.removedNodes, 'removed');
  renderNodes('changed-nodes', comparison.changedNodes, 'changed');
  renderEdges('added-edges', comparison.addedEdges, 'added');
  renderEdges('removed-edges', comparison.removedEdges, 'removed');
  renderEdges('changed-edges', comparison.changedEdges, 'changed');
}());
'@

        Write-AtlasTextFile -Path $comparisonAppPath -Content $comparisonApplication

        $html = @"
<!doctype html>
<html lang="en-GB">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self' data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'">
  <meta name="referrer" content="no-referrer">
  <title>Identity Atlas comparison</title>
  <style>
    :root { color-scheme: light; --ink: #102033; --muted: #5d6c7d; --line: #dde5ee; --panel: #ffffff; --canvas: #f5f7fa; --brand: #102033; --accent: #49a4ad; --added: #0f7b48; --removed: #a23d3d; --changed: #8a6517; }
    * { box-sizing: border-box; }
    body { margin: 0; background: var(--canvas); color: var(--ink); font-family: Arial, "Helvetica Neue", sans-serif; line-height: 1.45; }
    header { padding: 32px; background: var(--brand); color: #fff; }
    header p { margin: 6px 0 0; color: #c7d3df; }
    main { display: grid; gap: 18px; padding: 24px; }
    .cards { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
    .card, section { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; box-shadow: 0 8px 24px rgb(15 29 45 / 7%); }
    .card { padding: 18px; }
    .label { display: block; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; }
    .value { display: block; margin-top: 8px; font-size: 28px; font-weight: 700; }
    section { overflow: hidden; }
    section h2 { margin: 0; padding: 18px 20px; border-bottom: 1px solid var(--line); font-size: 18px; }
    .item { display: grid; gap: 4px; padding: 14px 20px; border-top: 1px solid #edf1f5; }
    .item:first-of-type { border-top: 0; }
    .meta { color: var(--muted); font-size: 13px; }
    .added { color: var(--added); }
    .removed { color: var(--removed); }
    .changed { color: var(--changed); }
    pre { margin: 8px 0 0; padding: 12px; overflow: auto; background: #f7f9fb; border: 1px solid var(--line); border-radius: 10px; font-size: 12px; }
    @media (max-width: 720px) { header, main { padding: 18px; } }
  </style>
</head>
<body>
  <header>
    <span class="label">Identity Atlas</span>
    <h1>Report comparison</h1>
    <p id="comparison-range"></p>
  </header>
  <main>
    <div class="cards" id="summary-cards"></div>
    <section><h2 class="added">Added objects</h2><div id="added-nodes"></div></section>
    <section><h2 class="removed">Removed objects</h2><div id="removed-nodes"></div></section>
    <section><h2 class="changed">Changed objects</h2><div id="changed-nodes"></div></section>
    <section><h2 class="added">Added relationships</h2><div id="added-edges"></div></section>
    <section><h2 class="removed">Removed relationships</h2><div id="removed-edges"></div></section>
    <section><h2 class="changed">Changed relationships</h2><div id="changed-edges"></div></section>
  </main>
  <script defer src="comparison-data.js"></script>
  <script defer src="comparison-app.js"></script>
</body>
</html>
"@

        Write-AtlasTextFile -Path $htmlPath -Content $html
    }

    return $comparison
}