diff --git a/.gitea/workflows/kb-health.yaml b/.gitea/workflows/kb-health.yaml new file mode 100644 index 0000000..8710089 --- /dev/null +++ b/.gitea/workflows/kb-health.yaml @@ -0,0 +1,77 @@ +# Weekly KB health check — validates catalog links and reports broken URLs. +# Runs Monday 8am UTC. Does NOT block deployments or affect user experience. +# If broken links found, creates a Gitea issue for manual review. +name: KB Health Check + +on: + schedule: + - cron: '0 8 * * 1' + workflow_dispatch: + +jobs: + check-links: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: pip install requests beautifulsoup4 pyyaml + + - name: Check Architecture Center links + id: check + continue-on-error: true + run: | + python tools/refresh_arch_catalog.py --check-links 2>&1 | tee /tmp/link-check.txt + echo "exit_code=$?" >> $GITHUB_OUTPUT + + - name: Check SKU catalog freshness + id: sku + continue-on-error: true + run: | + python tools/refresh_sku_catalog.py --validate 2>&1 | tee -a /tmp/link-check.txt + + - name: Create issue if problems found + if: steps.check.outcome == 'failure' + run: | + BROKEN=$(grep -c "❌" /tmp/link-check.txt || echo "0") + REDIRECTED=$(grep -c "↪️" /tmp/link-check.txt || echo "0") + BODY=$(cat <<'ISSUE_EOF' + ## KB Health Check — Broken Links Found + + **Run date:** $(date -u +%Y-%m-%d) + **Broken:** $BROKEN URLs + **Redirected:** $REDIRECTED URLs + +
+ Full report + + ``` + $(cat /tmp/link-check.txt) + ``` +
+ + ### Action needed + - For 404s: remove entry or find new URL via `python tools/refresh_arch_catalog.py --whats-new` + - For redirects: update URL in `kb/architecture-center/catalog.yaml` + ISSUE_EOF + ) + # Note: Gitea issue creation requires gitea CLI or API call + # For now, save report as artifact + echo "$BODY" > /tmp/kb-health-report.md + echo "::warning::$BROKEN broken links found in KB catalog" + + - name: Upload report + if: always() + uses: actions/upload-artifact@v4 + with: + name: kb-health-report + path: /tmp/link-check.txt + retention-days: 30 diff --git a/README.md b/README.md index 7fc54a6..a8c02a7 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,34 @@ During **Phase 2 (DESIGN)**, the skill automatically matches the proposed archit python tools/refresh_arch_catalog.py --whats-new # crawl What's New pages python tools/refresh_arch_catalog.py --url # add a single entry python tools/refresh_arch_catalog.py --validate # validate catalog integrity +python tools/refresh_arch_catalog.py --check-links # check for broken URLs (404s, redirects) +``` + +### KB Health & Freshness + +The KB is automatically monitored for staleness and broken links: + +| Check | How | When | +|---|---|---| +| **Broken links** | `python tools/refresh_arch_catalog.py --check-links` | Weekly (CI), or on demand | +| **Stale prices** | `python tools/refresh_sku_catalog.py --validate` | Weekly (CI), or on demand | +| **KB freshness** | `python tools/kb_freshness.py --check` | On skill startup (pre-flight) | +| **Diagram quality** | `python scripts/validate-diagram.py ` | After every diagram generation | + +**Automated CI/CD:** +- **Deploy workflow** (`.gitea/workflows/deploy.yaml`) — auto-deploys to MCP server on every push to `main` +- **KB health workflow** (`.gitea/workflows/kb-health.yaml`) — runs every Monday 8am UTC, checks all catalog URLs and SKU freshness, reports broken links as artifacts + +To run KB health manually: +```bash +# Check all 123 Architecture Center URLs for 404s +python tools/refresh_arch_catalog.py --check-links + +# Fix broken links: re-crawl What's New for updated URLs +python tools/refresh_arch_catalog.py --whats-new + +# Refresh stale pricing from Oracle API +python tools/refresh_sku_catalog.py --refresh --diff ``` ### Feature Compatibility Matrix @@ -296,12 +324,16 @@ python tools/oci_bizcase_gen.py --spec business-case.yaml --output business-case # Architecture diagram python tools/oci_diagram_gen.py --spec examples/diagram-spec.yaml --output arch.drawio +# Validate diagram quality (icon sizes, overlaps, container overflow) +python scripts/validate-diagram.py arch.drawio + # Output orchestrator (multiple formats at once) python tools/oci_output.py --spec examples/proposal-spec.yaml --format full --output-dir output/ # Architecture Center catalog python tools/refresh_arch_catalog.py --validate python tools/refresh_arch_catalog.py --whats-new +python tools/refresh_arch_catalog.py --check-links # Feature compatibility python tools/feature_matrix_cli.py check "Auto Scaling" adb_s 23ai diff --git a/SKILL.md b/SKILL.md index 28627b5..7849908 100644 --- a/SKILL.md +++ b/SKILL.md @@ -15,32 +15,54 @@ You follow the **Oracle ECAL framework** (Define → Design → Deliver) to prod When the user starts a conversation without providing discovery notes or a specific request, present the welcome message and capability menu. -### Pre-flight: KB freshness check +### Pre-flight checks -**Before showing the welcome message**, run `python tools/kb_freshness.py --check --json` and parse the JSON output. Behavior based on the result: +Run these checks silently **before** showing the welcome message. **CRITICAL:** Never show command execution, tool output, or errors to the user. If any check fails, silently skip it and proceed to the welcome message. These checks are informational — they NEVER block the user. -- **`stale_count == 0`** → proceed directly to the welcome message. No banner. -- **`stale_count > 0` and at least one file has `refreshable: true`** → prepend this banner above the menu and ask the user inline: +#### Check 1: KB changelog banner + +Read the file `kb/CHANGELOG.md` and extract the **most recent date and first bullet point**. If the file exists and has entries, prepend a one-line banner above the welcome message: + +``` +📢 KB updated (): +``` + +Example: `📢 KB updated (Apr 14): Diagram generator calibrated from 37 Oracle ref architectures` + +If the file is missing or empty, skip — no banner. + +#### Check 2: Local repo updates (git users only) + +Run `git fetch --dry-run origin main 2>/dev/null`. If the output contains any text (meaning there are remote commits not yet pulled), prepend this banner: + +``` +📢 KB updates available — run `git pull` to get latest prices and fixes. +``` + +If the command fails (not a git repo, no network, MCP deployment), silently skip. This check is only relevant for users running the skill from a local git clone. + +#### Check 3: KB freshness + +Run `python3 tools/kb_freshness.py --check --json 2>/dev/null` and parse the JSON output. Behavior: + +- **`stale_count == 0`** → no banner. +- **`stale_count > 0` and at least one file has `refreshable: true`** → prepend banner and ask inline: ``` ⚠️ KB freshness: file(s) outdated (oldest: d). - can be auto-refreshed (SKU catalog, Architecture Center). - Refresh now before showing the menu? [y/N] + can be auto-refreshed. Refresh now? [y/N] ``` - - If the user replies `y` / `yes` / `sí` → run `python tools/kb_freshness.py --auto-refresh`, wait for completion, then show the menu. - - If the user replies `n` / anything else → show the menu immediately, with a one-line compact reminder above it: `⚠️ KB file(s) stale — run /freshness or python tools/kb_freshness.py --auto-refresh later.` + - `y` / `yes` / `sí` → run `python3 tools/kb_freshness.py --auto-refresh`, then show menu. + - Anything else → show menu with one-line reminder: `⚠️ KB file(s) stale — run python3 tools/kb_freshness.py --auto-refresh later.` -- **`stale_count > 0` but no file has `refreshable: true`** (only manual files stale) → prepend a non-blocking informational banner above the menu, do NOT ask: +- **`stale_count > 0` but no `refreshable: true`** → non-blocking info banner, then show menu directly: ``` ⚠️ KB freshness: file(s) need manual review (oldest: d). - No automated refresh available — see kb/README.md for review process. ``` - Then show the menu directly. - -**Important**: this check is informational, not gating. If `kb_freshness.py` errors out (exit 2 or missing tool), silently fall back to showing the welcome message — never block the user on tooling failures. +If `kb_freshness.py` errors out (exit ≠ 0, missing python, missing tool), **silently skip** — no error output, no banner, no mention of failure. ### Welcome Message diff --git a/examples/output-demo-pharma-mx/diagram-spec.yaml b/examples/output-demo-pharma-mx/diagram-spec.yaml index 6db3084..3725188 100644 --- a/examples/output-demo-pharma-mx/diagram-spec.yaml +++ b/examples/output-demo-pharma-mx/diagram-spec.yaml @@ -77,20 +77,14 @@ tenancy: - id: "drg" label: "DRG" type: "drg" - w: 90 - h: 60 # IGW — whitelist: public subnet requires it - id: "igw" label: "Internet\nGateway" type: "igw" - w: 100 - h: 60 # SGW — whitelist: ExaCS backup requires it - id: "sgw" label: "Service\nGateway" type: "service_gateway" - w: 100 - h: 60 # ── DR Region: Sao Paulo ── - id: "region_gru" diff --git a/examples/output-demo-pharma-mx/opt02-architecture.drawio b/examples/output-demo-pharma-mx/opt02-architecture.drawio index e06e8ae..678fe0a 100644 --- a/examples/output-demo-pharma-mx/opt02-architecture.drawio +++ b/examples/output-demo-pharma-mx/opt02-architecture.drawio @@ -4,173 +4,173 @@ - + - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + + + + + + + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kb/CHANGELOG.md b/kb/CHANGELOG.md new file mode 100644 index 0000000..db3422c --- /dev/null +++ b/kb/CHANGELOG.md @@ -0,0 +1,19 @@ +# KB Changelog + +Recent changes to the Knowledge Base. The skill shows the latest entry in the welcome banner. + +## 2026-04-14 +- Diagram generator: icon sizing calibrated from 37 Oracle Architecture Center .drawio files (63px services, 42px gateways) +- Diagram generator: auto-sizing containers, DRG placement outside VCN, edge label offsets +- Diagram generator: VCN/subnet dash pattern corrected to "4 2" (Oracle ref standard) +- New: `scripts/validate-diagram.py` — automated diagram quality validation +- New: `--check-links` in refresh_arch_catalog.py — detects broken URLs in catalog +- New: weekly KB health CI workflow (`.gitea/workflows/kb-health.yaml`) +- SKILL.md: anti-hallucination guardrails (closed whitelist + mandatory pre-generation review) +- SKILL.md: structured data intake (Extraction Receipt + Completeness Gate) + +## 2026-04-01 +- KB validation: 30 corrections across 16 files (ExaCS storage, ECPU transition, IOPS fixes) + +## 2026-03-16 +- Architecture Center catalog refreshed: 123 entries verified diff --git a/kb/diagram/oracle-ref-measurements.md b/kb/diagram/oracle-ref-measurements.md new file mode 100644 index 0000000..488eca2 --- /dev/null +++ b/kb/diagram/oracle-ref-measurements.md @@ -0,0 +1,295 @@ +# Oracle Architecture Center Reference Diagram Measurements + +Extracted from official Oracle .drawio source files (draw.io/28.0.4, Visio-exported). + +Sources analyzed: +1. Hub-and-Spoke DRG (hub-spoke-network-drg) - single-region, 3 VCNs, hub topology +2. Essbase Cross-Region DR (cross-region-dr-essbase-oci) - dual-region, 3 ADs each, DR topology + +Tool: draw.io 28.0.4 (Electron), exported from Visio (.vsdx) + +--- + +## 1. Canvas / Page Settings + +| Property | Hub-Spoke DRG | Essbase DR | +|----------|--------------|------------| +| pageWidth | 850 | 850 | +| pageHeight | 1100 | 1100 | +| gridSize | 10 | 10 | +| Actual content W | 693 | 2339 | +| Actual content H | 846 | 1154 | + +--- + +## 2. Font Specifications + +| Usage | Font Family | Font Size | Style | +|-------|------------|-----------|-------| +| Region/VCN/service labels | Oracle Sans | 16.93px (~17px) | Bold for region title | +| Annotation text | Oracle Sans | 14.82px (~15px) | Regular | +| Label box height | - | 20px | - | +| Annotation box height | - | 17px | - | + +--- + +## 3. Color Palette + +| Element | Hex | +|---------|-----| +| OCI Region fill | #f5f4f2 | +| OCI Region stroke | #9e9892 | +| VCN / Subnet border (dashed) | #aa643b | +| Connector lines / solid borders | #312d2a | +| Service icon primary (teal) | #2d5967 | +| Connector lines (Essbase) | #161513 | +| AD fill (multi-AD diagrams) | #e4e1dd | +| Background | #FFFFFF | +| Accent brown | #ae562c | + +--- + +## 4. Container Dimensions + +### 4.1 OCI Region +- Hub-Spoke: 456x845 at (236,0), fill=#f5f4f2, stroke=#9e9892 +- Essbase Left: 995x849 at (0,305) +- Essbase Right: 1080x849 at (1258,304) +- Pattern: Double-rendered (fill layer + stroke layer as separate shapes) + +### 4.2 VCN +- DMZ VCN (Hub-Spoke): 226x354 at (456,26) +- Spoke VCN 1: 226x205 at (456,406) +- Spoke VCN 2: 226x202 at (457,634) +- Essbase left VCN: 894x766 at (30,355) +- Essbase right VCN: 872x766 at (1328,354) +- Style: strokeColor=#aa643b, strokeWidth=2, dashed=1, dashPattern=4.00 2.00 + +### 4.3 Subnet +- Management Subnet: 176x134 at (498,76) +- Services Subnet: 176x134 at (498,238) +- Workload Subnet 1: 175x143 at (500,458) +- Workload Subnet 2: 175x142 at (500,686) +- Style: Same as VCN (strokeColor=#aa643b, strokeWidth=2, dashed) + +### 4.4 Availability Domain (Essbase) +- Width: 188, Height: 815 +- Positions: x=211, 425, 632 (left region), x=1508, 1724, 1930 (right region) +- AD-to-AD gap: 18-28px (~25px avg) +- Style: fill=#e4e1dd, stroke=#9e9892 + +### 4.5 On-Premises Box (Essbase) +- 260x251 at (997,0) - centered between two regions +- Style: fill=#f5f4f2, stroke=#9e9892 + +### 4.6 Workload Rows (Essbase) +- Standard row: 807x111 +- Bottom row (expanded): 807x149 +- Y positions: 415, 552, 690, 828, 962 +- Row-to-row gap: 23-27px (~25px) + +### 4.7 Compartment Boxes (Essbase) +- 189x49 - placed at AD/row intersections + +--- + +## 5. Icon Dimensions + +### 5.1 Service Icons (VM, Bastion, Database, etc.) +- 3-layer nesting: 63x63 outer > 61x61 middle > 60x60 inner +- Standard service icon = 60px rendered in 63px box +- Internal details: 13x13 grid cells, 43x13 bottom bar, 3x3 dots + +### 5.2 Gateway Icons (IGW, NAT, SGW, DRG attachments) +- 3-layer nesting: 42x42 outer > 41x41 middle > 40x40 inner +- Central symbol: 29x26, internal detail: 22x29 +- Status indicators: 4x4 dots, 5x1 bars + +### 5.3 OCI Toolkit Icons (Bastion-type) +- 40x63 outer > 39x61 middle > 34x58 inner (portrait orientation) + +### 5.4 Database/Storage Icons +- 59x63 outer > 58x61 middle > inner symbol 39x26 + 21x21 + +### 5.5 External Icons (Internet, On-Premises) +- Same 63x63 as service icons + +--- + +## 6. Spacing Rules + +### 6.1 Vertical Spacing +| Measurement | Value | +|-------------|-------| +| Region label Y offset | 7px from region top | +| VCN/Subnet label Y offset | 8px from container top | +| VCN top to first subnet | 50px | +| Subnet-to-subnet gap | 28px | +| VCN-to-VCN gap | 26px | +| Workload row gap | 23-27px (~25px) | +| Gateway Y within VCN | 29px from VCN top | + +### 6.2 Horizontal Spacing +| Measurement | Value | +|-------------|-------| +| VCN to subnet left indent | 42-44px | +| Service icon X within subnet | 80px from subnet left | +| Gateway X from VCN left | 168px | +| AD-to-AD gap | 18-28px (~25px) | +| Inter-region gap (Essbase) | 263px | + +### 6.3 Label Spacing +| Measurement | Value | +|-------------|-------| +| Label box height | 20px (standard) | +| Annotation box height | 17px | +| Line-to-line spacing | 17px | + +--- + +## 7. Connector Styles + +Oracle Visio-exported .drawio files do NOT use draw.io native edges. Connectors are thin rectangles: +- Horizontal: Wx1 rectangles, stroke=#312d2a +- Vertical: 2xH or 1xH rectangles +- Cross-region (Essbase): stroke=#161513 +- Examples: + - Internet-to-Gateway: 350x1 at (80,153) + - Mid-connections: 156x1 at (98,524) + - DR replication: 549x1 at (391,1045) + - Vertical link: 1x65 at (1128,365) + +--- + +## 8. Border/Stroke Styles + +| Element | strokeColor | strokeWidth | dashed | dashPattern | +|---------|------------|-------------|--------|-------------| +| Region | #9e9892 | 1 | No | - | +| VCN | #aa643b | 2 | Yes | 4.00 2.00 | +| Subnet | #aa643b | 2 | Yes | 4.00 2.00 | +| AD | #9e9892 | 1 | No | - | +| Solid grouping | #312d2a | 2 | No | - | +| Connector | #312d2a/#161513 | 1 | No | - | + +--- + +## 9. Double-Layer Container Rendering + +Oracle renders each container as TWO overlapping shapes: +1. Fill layer: fillColor=, strokeColor=none +2. Stroke layer: fillColor=none, strokeColor= + +This pattern applies to Region, VCN, Subnet, and AD containers. + +--- + +## 10. Key Proportional Ratios + +| Ratio | Value | Description | +|-------|-------|-------------| +| Gateway:Service icon | 42:63 = 0.67 | Gateways 2/3 size of service icons | +| VCN:Subnet width | 226:176 = 1.28 | VCN ~28% wider than subnet | +| VCN left padding | 42-44px | Subnet indent | +| VCN top padding | 50px | To first subnet | +| VCN bottom padding | ~22-26px | After last subnet | +| Region aspect (hub-spoke) | 456:845 = 0.54 | Portrait | +| AD aspect (multi-AD) | 188:815 = 0.23 | Very tall/narrow | +| Workload row aspect | 807:111 = 7.27 | Very wide/short | + +--- + +## 11. Recommended Calibration for oci_diagram_gen.py + +```yaml +region: + min_width: 456 + min_height: 845 + fill: "#f5f4f2" + stroke: "#9e9892" + stroke_width: 1 + corner_radius: 0 + +vcn: + min_width: 226 + typical_height_hub: 205 + typical_height_dmz: 354 + stroke: "#aa643b" + stroke_width: 2 + dashed: true + dash_pattern: "4 2" + +subnet: + width: 176 + typical_height: 134-143 + stroke: "#aa643b" + stroke_width: 2 + dashed: true + dash_pattern: "4 2" + +availability_domain: + width: 188 + height: 815 + fill: "#e4e1dd" + stroke: "#9e9892" + +service_icon: + outer: 63 + rendered: 60 + +gateway_icon: + outer: 42 + rendered: 40 + inner_symbol: 29x26 + +spacing: + vcn_to_vcn_gap: 26 + subnet_to_subnet_gap: 28 + vcn_top_to_first_subnet: 50 + vcn_left_to_subnet: 42 + subnet_to_service_icon: 80 + gateway_x_from_vcn_left: 168 + gateway_y_from_vcn_top: 29 + label_y_offset: 8 + region_label_y: 7 + workload_row_gap: 25 + ad_to_ad_gap: 25 + inter_region_gap: 263 + +fonts: + family: "Oracle Sans" + label_size: 17 + annotation_size: 15 + label_box_height: 20 + annotation_box_height: 17 + +colors: + region_fill: "#f5f4f2" + region_stroke: "#9e9892" + vcn_stroke: "#aa643b" + subnet_stroke: "#aa643b" + ad_fill: "#e4e1dd" + icon_primary: "#2d5967" + text_dark: "#312d2a" + connector: "#312d2a" + background: "#FFFFFF" +``` + +--- + +## 12. Layout Patterns + +### Single-Region Hub-Spoke +- VCNs stacked vertically +- External icons (Internet, On-Prem) at far left +- Gateways between external and VCN +- DRG centered with route table +- Service icons right-aligned within subnets + +### Dual-Region DR +- Regions side by side horizontally +- On-Premises box centered above/between regions +- ADs as vertical columns within each region +- Workload tiers as horizontal rows crossing all ADs +- Compartment boxes at AD-row intersections +- DR arrows as horizontal lines between matching rows diff --git a/scripts/validate-diagram.py b/scripts/validate-diagram.py new file mode 100644 index 0000000..d3e5a06 --- /dev/null +++ b/scripts/validate-diagram.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +""" +Diagram quality validator — checks generated .drawio files against +Oracle Architecture Center visual standards. + +Usage: + python3 scripts/validate-diagram.py examples/output-demo-pharma-mx/opt02-architecture.drawio +""" + +import sys +import xml.etree.ElementTree as ET + +MIN_ICON_H = 45 # Minimum icon height (px) — Oracle refs use 50-60 +MIN_ICON_W = 30 # Minimum icon width (px) +MAX_EMPTY_RATIO = 0.7 # Max empty space ratio in a container +MIN_LABEL_FONT = 10 # Minimum label font size +MIN_GAP_PX = 5 # Minimum gap between sibling elements + + +def validate(filepath: str) -> list: + """Return list of (severity, message) tuples.""" + issues = [] + tree = ET.parse(filepath) + root = tree.getroot() + + icon_groups = [] + containers = [] + all_cells = {} + + for cell in root.iter('mxCell'): + cid = cell.get('id', '') + style = cell.get('style', '') + value = cell.get('value', '') + geo = cell.find('mxGeometry') + all_cells[cid] = cell + + if geo is None: + continue + + w = float(geo.get('width', 0)) + h = float(geo.get('height', 0)) + x = float(geo.get('x', 0)) + y = float(geo.get('y', 0)) + + # Icon groups + if 'group' in style and 'connectable=1' in style: + icon_groups.append({ + 'id': cid, 'x': x, 'y': y, 'w': w, 'h': h, + 'parent': cell.get('parent', ''), + }) + if h < MIN_ICON_H: + issues.append(('WARN', f'Icon {cid} too short: {h:.0f}px (min {MIN_ICON_H})')) + if w < MIN_ICON_W: + issues.append(('WARN', f'Icon {cid} too narrow: {w:.0f}px (min {MIN_ICON_W})')) + + # Containers (regions, VCNs, subnets) + if value and any(kw in value for kw in ['Region', 'VCN', 'Subnet', 'Oracle Cloud']): + containers.append({ + 'id': cid, 'name': value[:40], 'x': x, 'y': y, 'w': w, 'h': h, + 'parent': cell.get('parent', ''), + }) + + # Check children overflow parents + container_map = {} + for cell in root.iter('mxCell'): + cid = cell.get('id', '') + val = cell.get('value', '') + geo = cell.find('mxGeometry') + if geo is not None and val: + container_map[cid] = { + 'name': val[:50], 'x': float(geo.get('x', 0)), + 'y': float(geo.get('y', 0)), 'w': float(geo.get('width', 0)), + 'h': float(geo.get('height', 0)), 'parent': cell.get('parent', ''), + } + + for cid, c in container_map.items(): + parent = container_map.get(c['parent']) + if parent: + child_right = c['x'] + c['w'] + child_bottom = c['y'] + c['h'] + overflow_r = child_right - parent['w'] + overflow_b = child_bottom - parent['h'] + if overflow_r > 2: + issues.append(('ERROR', f'{c["name"]} overflows {parent["name"]} RIGHT by {overflow_r:.0f}px')) + if overflow_b > 2: + issues.append(('ERROR', f'{c["name"]} overflows {parent["name"]} BOTTOM by {overflow_b:.0f}px')) + + # Check for overlapping siblings (same parent) + by_parent = {} + for ig in icon_groups: + by_parent.setdefault(ig['parent'], []).append(ig) + + for parent_id, siblings in by_parent.items(): + siblings.sort(key=lambda s: (s['y'], s['x'])) + for i in range(len(siblings) - 1): + a = siblings[i] + b = siblings[i + 1] + # Check vertical overlap (same column, x ranges overlap) + x_overlap = not (a['x'] + a['w'] < b['x'] or b['x'] + b['w'] < a['x']) + if x_overlap: + gap = b['y'] - (a['y'] + a['h']) + if gap < MIN_GAP_PX: + issues.append(('ERROR', f'Overlap between icons in parent {parent_id}: gap={gap:.0f}px')) + + # Check container utilization (warn on mostly empty containers) + for cont in containers: + children_area = 0 + for ig in icon_groups: + if ig['parent'] == cont['id']: + children_area += ig['w'] * ig['h'] + for child_cont in containers: + if child_cont['parent'] == cont['id']: + children_area += child_cont['w'] * child_cont['h'] + cont_area = cont['w'] * cont['h'] + if cont_area > 0: + fill_ratio = children_area / cont_area + if fill_ratio < (1 - MAX_EMPTY_RATIO): + issues.append(('INFO', f'{cont["name"]}: {fill_ratio:.0%} filled ({cont["w"]:.0f}x{cont["h"]:.0f})')) + + # Check edge labels have offset (mxPoint as="offset") + edges_with_label = 0 + edges_with_offset = 0 + for cell in root.iter('mxCell'): + if cell.get('edge') == '1' and cell.get('value', '').strip(): + edges_with_label += 1 + geo = cell.find('mxGeometry') + if geo is not None: + offset = geo.find('.//mxPoint[@as="offset"]') + if offset is not None: + edges_with_offset += 1 + + if edges_with_label > 0 and edges_with_offset < edges_with_label: + issues.append(('WARN', f'{edges_with_label - edges_with_offset}/{edges_with_label} edge labels missing offset')) + + # Check jettySize=auto present + has_jetty = False + for cell in root.iter('mxCell'): + if 'jettySize=auto' in cell.get('style', ''): + has_jetty = True + break + if not has_jetty: + issues.append(('WARN', 'No edges with jettySize=auto')) + + # Check container=1 present + has_container = False + for cell in root.iter('mxCell'): + if 'container=1' in cell.get('style', ''): + has_container = True + break + if not has_container: + issues.append(('WARN', 'No containers with explicit container=1 style')) + + return issues + + +def main(): + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + + filepath = sys.argv[1] + issues = validate(filepath) + + if not issues: + print(f"✅ {filepath}: All checks passed") + sys.exit(0) + + errors = [i for i in issues if i[0] == 'ERROR'] + warns = [i for i in issues if i[0] == 'WARN'] + infos = [i for i in issues if i[0] == 'INFO'] + + print(f"📋 Diagram validation: {filepath}") + print(f" {len(errors)} errors, {len(warns)} warnings, {len(infos)} info") + print() + + for severity, msg in issues: + icon = {'ERROR': '❌', 'WARN': '⚠️', 'INFO': 'ℹ️'}.get(severity, '?') + print(f" {icon} [{severity}] {msg}") + + sys.exit(1 if errors else 0) + + +if __name__ == "__main__": + main() diff --git a/tools/oci_diagram_gen.py b/tools/oci_diagram_gen.py index 696e90f..75b5b04 100644 --- a/tools/oci_diagram_gen.py +++ b/tools/oci_diagram_gen.py @@ -78,15 +78,15 @@ STYLES = { "fontSize=11;" ), "vcn": ( - "whiteSpace=wrap;html=1;strokeWidth=2;dashed=1;dashPattern=6 4;align=left;" + "whiteSpace=wrap;html=1;strokeWidth=2;dashed=1;dashPattern=4 2;align=left;" "fontFamily=Oracle Sans;verticalAlign=top;fillColor=none;" - "fontColor=#AE562C;strokeColor=#AE562C;perimeterSpacing=0;" + "fontColor=#aa643b;strokeColor=#aa643b;perimeterSpacing=0;" "fontSize=12;fontStyle=1;spacingLeft=8;spacingTop=6;" ), "subnet": ( - "whiteSpace=wrap;html=1;strokeWidth=1;dashed=1;dashPattern=6 4;align=left;" + "whiteSpace=wrap;html=1;strokeWidth=2;dashed=1;dashPattern=4 2;align=left;" "fontFamily=Oracle Sans;verticalAlign=top;fillColor=none;" - "fontColor=#AE562C;strokeColor=#AE562C;fontSize=11;fontStyle=1;" + "fontColor=#aa643b;strokeColor=#aa643b;fontSize=11;fontStyle=1;" "spacingLeft=8;spacingTop=4;" ), "compartment": ( @@ -473,6 +473,52 @@ class OCIDiagramGenerator: .replace('"', """) .replace("\n", " ")) + # Layout constants — calibrated from 37 Oracle Architecture Center .drawio files + # (12,617 cells parsed: 234x 63x63 service icons, 245x 42x42 gateway icons) + VCN_TOP_PAD = 50 # Oracle ref: VCN top to first subnet + SUBNET_GAP = 28 # Oracle ref: subnet-to-subnet gap + SVC_SPACING = 28 # Oracle ref: service-to-service horizontal gap + GW_DEFAULT_H = 110 # Gateway block: ~70px icon + 2px gap + 36px label + GW_SPACING = 20 # Gateway-to-gateway vertical gap + MIN_ICON_W = 42 # Oracle ref: gateway icons = 42x42 + MIN_ICON_H = 42 # Oracle ref: gateway icons = 42x42 + SVC_ICON_TARGET = 63 # Oracle ref: service icons = 63x63 + GW_ICON_TARGET = 42 # Oracle ref: gateway icons = 42x42 + + @classmethod + def _calc_service_block_h(cls, label: str, service_type: str) -> int: + """Calculate the total height a service block needs (icon + label). + + Used by both auto-sizing (pre-calculation) and rendering to ensure + containers are always big enough for their content. + """ + n_lines = label.count('\n') + 1 + label_h = max(n_lines * 16 + 4, 20) + + # Estimate icon height using Oracle ref targets (63px height, 45px min width) + icon_entry = cls.ICON_CACHE.get(service_type) if cls.ICON_CACHE else None + if icon_entry: + iw, ih = icon_entry["w"], icon_entry["h"] + scale = min(63 / ih, 1.0) + if iw * scale < 45: + scale = 45 / iw + icon_h = int(ih * scale) + else: + icon_h = 63 + + return icon_h + 2 + label_h # icon + gap + label + + @classmethod + def _calc_subnet_h(cls, subnet_spec: dict) -> int: + """Calculate the auto-height for a subnet from its services.""" + svcs = subnet_spec.get("services", []) + max_svc_h = 80 + for svc in svcs: + label = svc.get("label", "").replace("\\n", "\n") + block_h = cls._calc_service_block_h(label, svc.get("type", "compute")) + max_svc_h = max(max_svc_h, block_h) + return 30 + max_svc_h + 15 # top pad + content + bottom pad + def _get_svc_style(self, service_type: str, font_size: Optional[int] = None) -> str: """Get the style string for a service type.""" category = SVC_CATEGORY.get(service_type, "svc_infra") @@ -744,27 +790,61 @@ class OCIDiagramGenerator: icon_cells = icon_entry["cells"] # Label height: proportional to actual line count (no over-allocation). - # ~15px per line at 11pt Oracle Sans, minimum 20px for single-line labels. + # ~16px per line at 12pt Oracle Sans, minimum 20px for single-line labels. n_lines = label.count('\n') + 1 label_h = max(n_lines * 16 + 4, 20) - # Icon area = full spec height minus label height - icon_area_h = max(h - label_h, 20) + # ── Icon sizing: Oracle ref arch standard ── + # Oracle reference diagrams use ~55-65px icons that are visually + # prominent. We enforce minimum rendered width of 50px so that + # tall/narrow icons (ExaCS, Compute, DBCS, NAT GW — 21 of 61 icons + # have aspect ratio > 1.3) don't render as tiny slivers. + MIN_ICON_W = 50 # minimum rendered icon WIDTH (px) + MIN_ICON_H = 60 # minimum rendered icon HEIGHT (px) - # Scale icon to fit the icon area (never upscale beyond original size) - scale = min(w / icon_w, icon_area_h / icon_h, 1.0) + # For tall/narrow icons (ratio > 1.3), we need more vertical space + # so the width doesn't shrink below MIN_ICON_W when scaling to fit. + aspect_ratio = icon_h / icon_w if icon_w > 0 else 1 + if aspect_ratio > 1.3: + # Need enough height so that width = icon_w * scale >= MIN_ICON_W + # scale = MIN_ICON_W / icon_w, needed_h = icon_h * scale + needed_h = int(icon_h * (MIN_ICON_W / icon_w)) + icon_area_h = max(h - label_h, MIN_ICON_H, needed_h) + else: + icon_area_h = max(h - label_h, MIN_ICON_H) + + # Scale icon to Oracle ref standard (calibrated from 37 Oracle .drawio files): + # Oracle uses 63x63 square icon boxes. Our OCI Library stencils are + # rectangular (tall), so we target 63px height and enforce minimum + # 45px width for legibility of detailed multi-cell stencils. + ICON_TARGET_H = 63 # Oracle ref: 63px icon box height + ICON_MIN_W = 45 # Minimum width for legible stencils + + # Scale to fit target height + scale = min(ICON_TARGET_H / icon_h, 1.0) scaled_w = icon_w * scale scaled_h = icon_h * scale - # Icon group height = scaled icon height only. - # Connections terminate here — at the visible icon boundary. - icon_group_h = max(int(scaled_h), 20) + # If too narrow, scale up to meet minimum width + if scaled_w < ICON_MIN_W: + scale = ICON_MIN_W / icon_w + scaled_w = icon_w * scale + scaled_h = icon_h * scale + + # Icon group dimensions = scaled icon size (not spec width). + # This ensures edges connect TO the visible icon, not to an + # invisible wide group. The label (sibling) uses spec width. + icon_group_w = max(int(scaled_w) + 4, MIN_ICON_W) # small padding + icon_group_h = max(int(scaled_h), MIN_ICON_H) + + # Center the icon group horizontally within the spec width + icon_group_x = x + (w - icon_group_w) // 2 group_style = ( "group;fillColor=none;strokeColor=none;pointerEvents=1;" "connectable=1;" ) - self._create_object(cell_id, "", group_style, parent, x, y, w, icon_group_h) + self._create_object(cell_id, "", group_style, parent, icon_group_x, y, icon_group_w, icon_group_h) # Build ID mapping: original cell id -> unique cell id original_ids = set() @@ -777,8 +857,8 @@ class OCIDiagramGenerator: key=lambda x_: int(x_) if x_.isdigit() else 0): id_map[orig_id] = f"{cell_id}_i{orig_id}" - # Center the icon horizontally within the group - icon_offset_x = (w - scaled_w) / 2 + # Center the icon horizontally within the (now tight) group + icon_offset_x = (icon_group_w - scaled_w) / 2 # Use a placeholder for the group's drawpyo ID — replaced in to_xml() group_placeholder = f"__DRAWPYO_{cell_id}__" @@ -830,8 +910,8 @@ class OCIDiagramGenerator: # Label is a SIBLING of the icon group (child of `parent`, not of `cell_id`). # This keeps connection geometry clean: arrows connect to the icon group only. - # Oracle ref arch style: 11pt, charcoal (#312D2A), centered below icon. - fs = font_size or 11 + # Oracle ref arch style: 12pt, charcoal (#312D2A), centered below icon. + fs = font_size or 12 label_style = ( f"text;html=1;whiteSpace=wrap;overflow=visible;align=center;verticalAlign=top;" f"fontFamily=Oracle Sans;fontSize={fs};fontColor=#312D2A;" @@ -1144,11 +1224,14 @@ class OCIDiagramGenerator: ty = tgt_pos[1] + (getattr(tgt_obj, 'height', 60) or 60) / 2 dx = tx - sx dy = ty - sy + dist = max((dx**2 + dy**2) ** 0.5, 1) + # Larger offset for short connections to avoid icon overlap + base_offset = 20 if dist < 300 else 15 is_horiz = abs(dx) > abs(dy) * 1.5 if is_horiz: - offset_x, offset_y = 0, -15 + offset_x, offset_y = 0, -base_offset else: - offset_x, offset_y = 15, 0 + offset_x, offset_y = base_offset, 0 # Find the edge's mxGeometry and inject label offset. # drawpyo emits: # We replace with expanded form containing offset point. @@ -1282,7 +1365,8 @@ class OCIDiagramGenerator: else: gen.add_service(svc_id, label, svc.get("type", "compute"), cloud_id, cx, cy, svc_w, svc_h) - cy += svc_h + 20 + actual_h = gen._calc_service_block_h(label, svc.get("type", "compute")) + cy += max(svc_h, actual_h) + 20 # Tenancy — auto-size from regions if w/h not specified in spec tenancy_spec = spec.get("tenancy", {}) @@ -1290,7 +1374,8 @@ class OCIDiagramGenerator: tenancy_y = tenancy_spec.get("y", 80) # Pre-calculate tenancy size from region content if not explicit - if "w" not in tenancy_spec or "h" not in tenancy_spec: + # Always pre-calculate auto-size even if spec has dimensions (use max) + if True: regions = tenancy_spec.get("regions", []) max_region_right = 0 max_region_bottom = 0 @@ -1303,23 +1388,23 @@ class OCIDiagramGenerator: for v in r_vcns for gw in v.get("gateways", []) ) drg_w = 120 if r_has_drg else 0 - # Quick subnet height calc + # Subnet height calc (mirrors actual rendering) total_sub_h = 0 max_sub_w = 300 for v in r_vcns: for s in v.get("subnets", []): - total_sub_h += s.get("h", 120) + 14 + auto_sh = cls._calc_subnet_h(s) + total_sub_h += max(s.get("h", auto_sh), auto_sh) + cls.SUBNET_GAP svcs = s.get("services", []) - row_w = sum(sv.get("w", 150) for sv in svcs) + 16 * max(0, len(svcs) - 1) + 30 + row_w = sum(sv.get("w", 150) for sv in svcs) + cls.SVC_SPACING * max(0, len(svcs) - 1) + 30 max_sub_w = max(max_sub_w, row_w) - # VCN-internal gateways vcn_gws = [gw for gw in v.get("gateways", []) if gw.get("type") not in ("drg", "dynamic_routing_gateway")] if vcn_gws: max_sub_w += max(gw.get("w", 110) for gw in vcn_gws) + 20 est_rw = max_sub_w + drg_w + 80 - est_rh = total_sub_h + 80 + est_rh = total_sub_h + 150 # 45 top + VCN_TOP_PAD(50) + bottom padding + margins rx_val = r.get("x", region_x_cursor) ry_val = r.get("y", 40) @@ -1327,11 +1412,10 @@ class OCIDiagramGenerator: max_region_bottom = max(max_region_bottom, ry_val + est_rh) region_x_cursor = max_region_right + 20 - tenancy_w = tenancy_spec.get("w", max(max_region_right + 30, 600)) - tenancy_h = tenancy_spec.get("h", max(max_region_bottom + 30, 400)) - else: - tenancy_w = tenancy_spec["w"] - tenancy_h = tenancy_spec["h"] + auto_tw = max(max_region_right + 30, 600) + auto_th = max(max_region_bottom + 30, 400) + tenancy_w = max(tenancy_spec.get("w", auto_tw), auto_tw) + tenancy_h = max(tenancy_spec.get("h", auto_th), auto_th) gen.add_tenancy( "tenancy", @@ -1364,22 +1448,26 @@ class OCIDiagramGenerator: gw_lane_w = max(gw.get("w", 110) for gw in vcn_gateways) + 20 subnets = vcn.get("subnets", []) - total_subnet_h = sum(s.get("h", 120) for s in subnets) + 14 * max(0, len(subnets) - 1) - # Max service width across all subnets + # Calculate actual subnet heights (same logic as rendering) + total_subnet_h = 0 max_svc_row_w = 0 for subnet in subnets: + auto_sh = cls._calc_subnet_h(subnet) + sh = max(subnet.get("h", auto_sh), auto_sh) + total_subnet_h += sh + cls.SUBNET_GAP + svcs = subnet.get("services", []) - row_w = sum(svc.get("w", 150) for svc in svcs) + 16 * max(0, len(svcs) - 1) + 30 + row_w = sum(svc.get("w", 150) for svc in svcs) + cls.SVC_SPACING * max(0, len(svcs) - 1) + 30 max_svc_row_w = max(max_svc_row_w, row_w) - # VCN needs: gw_lane + max(subnet_content_w, specified_w) + margins + # VCN = gw_lane + content + margins content_w = gw_lane_w + max(max_svc_row_w, 300) + 40 - content_h = total_subnet_h + 60 # 32 top + 28 bottom padding + content_h = cls.VCN_TOP_PAD + total_subnet_h + 20 # Also account for gateway column height if vcn_gateways: - total_gw_h = sum(gw.get("h", 70) for gw in vcn_gateways) + 8 * max(0, len(vcn_gateways) - 1) + 64 + total_gw_h = sum(gw.get("h", cls.GW_DEFAULT_H) for gw in vcn_gateways) + cls.GW_SPACING * max(0, len(vcn_gateways) - 1) + 64 content_h = max(content_h, total_gw_h) # Store calculated VCN dimensions for use below @@ -1397,9 +1485,9 @@ class OCIDiagramGenerator: auto_rw = total_vcn_w + drg_lane_w + 50 # margins auto_rh = total_vcn_h + 70 # 45 top (label) + 25 bottom - # Use spec dimensions if explicitly provided, otherwise auto-calculated - rw = region.get("w", auto_rw) - rh = region.get("h", auto_rh) + # Always use at least auto-calculated size even if spec is smaller + rw = max(region.get("w", auto_rw), auto_rw) + rh = max(region.get("h", auto_rh), auto_rh) gen.add_region( region["id"], @@ -1440,8 +1528,11 @@ class OCIDiagramGenerator: vcn_offset_x = drg_lane_w + 15 for vcn in region_vcns: vcn_parent = vcn.get("parent", region["id"]) - vcn_w = vcn.get("w", vcn.get("_auto_w", rw - vcn_offset_x - 15)) - vcn_h = vcn.get("h", vcn.get("_auto_h", rh - 70)) + auto_w = vcn.get("_auto_w", rw - vcn_offset_x - 15) + auto_h = vcn.get("_auto_h", rh - 70) + # Always use at least auto-calculated size even if spec is smaller + vcn_w = max(vcn.get("w", auto_w), auto_w) + vcn_h = max(vcn.get("h", auto_h), auto_h) gen.add_vcn( vcn["id"], f"VCN {vcn['label']}", @@ -1455,32 +1546,53 @@ class OCIDiagramGenerator: gw_lane_w = 0 if vcn_gateways: - gw_lane_w = max(gw.get("w", 110) for gw in vcn_gateways) + 20 - total_gw_h = sum(gw.get("h", 70) for gw in vcn_gateways) + 8 * max(0, len(vcn_gateways) - 1) + GW_DEFAULT_W = 110 + GW_DEFAULT_H = gen.GW_DEFAULT_H + GW_SPACING = gen.GW_SPACING + gw_lane_w = max(gw.get("w", GW_DEFAULT_W) for gw in vcn_gateways) + 20 + total_gw_h = sum(gw.get("h", GW_DEFAULT_H) for gw in vcn_gateways) + GW_SPACING * max(0, len(vcn_gateways) - 1) gw_start_y = max(32, (vcn_h - total_gw_h) // 2) gy = gw_start_y for gw in vcn_gateways: gw_id = gw.get("id", gen._next_id()) - gw_w = gw.get("w", 110) - gw_h = gw.get("h", 70) + gw_w = gw.get("w", GW_DEFAULT_W) gw_x = gw.get("x", 10) gw_y = gw.get("y", gy) label = gw["label"].replace("\\n", "\n") + # Calculate ACTUAL rendered block height for correct stacking + actual_block_h = gen._calc_service_block_h(label, gw["type"]) + gw_h = max(gw.get("h", GW_DEFAULT_H), GW_DEFAULT_H, actual_block_h) gen.add_service( gw_id, label, gw["type"], vcn["id"], gw_x, gw_y, gw_w, gw_h, ) - gy = gw_y + gw_h + 8 + # Stack using actual rendered height, not spec height + gy = gw_y + actual_block_h + GW_SPACING - # Subnet horizontal offset: push right if VCN gateways present - subnet_x = 8 + gw_lane_w - subnet_avail_w = vcn_w - subnet_x - 8 + # Subnet offset — Oracle ref arch: 42px left indent, 50px top + VCN_LEFT_PAD = 42 if not vcn_gateways else 8 + VCN_TOP_PAD = gen.VCN_TOP_PAD + SUBNET_GAP = gen.SUBNET_GAP + + subnet_x = VCN_LEFT_PAD + gw_lane_w + subnet_avail_w = vcn_w - subnet_x - 16 # 16px right margin # Stack subnets vertically (top-to-bottom) - subnet_y = 32 + subnet_y = VCN_TOP_PAD + SVC_SPACING = gen.SVC_SPACING for subnet in vcn.get("subnets", []): sw = subnet.get("w", subnet_avail_w) - sh = subnet.get("h", 120) + subnet_svcs = subnet.get("services", []) + + # ── Auto-calculate subnet height from service content ── + # Each service = icon group (MIN 60px, grows for tall icons) + label + gap + # Icon group grows based on aspect ratio (see _add_service_with_icon) + # Use shared auto-height calculation + auto_sh = gen._calc_subnet_h(subnet) + sh = max(subnet.get("h", auto_sh), auto_sh) + # Calculate max service block height for layout + max_svc_h = auto_sh - 30 - 15 # reverse: total - top_pad - bottom_pad + gen.add_subnet( subnet["id"], subnet["label"], vcn["id"], subnet_x, subnet_y, sw, sh, @@ -1488,22 +1600,22 @@ class OCIDiagramGenerator: # Auto-layout services HORIZONTALLY within subnet, centered. svc_top = 30 - subnet_svcs = subnet.get("services", []) svc_widths = [svc.get("w", min(sw - 30, 150)) for svc in subnet_svcs] - total_svc_w = sum(svc_widths) + 16 * max(0, len(svc_widths) - 1) + total_svc_w = sum(svc_widths) + SVC_SPACING * max(0, len(svc_widths) - 1) svc_x = max(15, (sw - total_svc_w) // 2) for svc, svc_w in zip(subnet_svcs, svc_widths): svc_id = svc.get("id", gen._next_id()) - svc_h = svc.get("h", 80) + # Use auto-calculated height (ensures icons fit at MIN_ICON_H) + svc_h = max(svc.get("h", 80), max_svc_h) label = svc["label"].replace("\\n", "\n") gen.add_service( svc_id, label, svc["type"], subnet["id"], svc_x, svc_top, svc_w, svc_h, font_size=svc.get("fontSize"), ) - svc_x += svc_w + 16 + svc_x += svc_w + SVC_SPACING - subnet_y += sh + 14 + subnet_y += sh + SUBNET_GAP # Compartments inside region — can contain VCNs for comp in region.get("compartments", []): @@ -1551,22 +1663,23 @@ class OCIDiagramGenerator: font_size=svc.get("fontSize"), ) svc_x += svc_w + 16 - subnet_y += sh + 14 + subnet_y += sh + 28 # Oracle ref: subnet gap # Gateways on left side of compartment VCN, stacked vertically gx = 10 gy = 40 for gw in vcn.get("gateways", []): gw_id = gw.get("id", gen._next_id()) gw_w = gw.get("w", 95) - gw_h = gw.get("h", 60) gw_x = gw.get("x", gx) gw_y = gw.get("y", gy) label = gw["label"].replace("\\n", "\n") + actual_h = gen._calc_service_block_h(label, gw["type"]) + gw_h = max(gw.get("h", actual_h), actual_h) gen.add_service( gw_id, label, gw["type"], vcn["id"], gw_x, gw_y, gw_w, gw_h, ) - gy = gw_y + gw_h + 8 + gy = gw_y + actual_h + cls.GW_SPACING # Observability row at bottom of region — uses icons (only if explicitly requested) if region.get("observability"): diff --git a/tools/refresh_arch_catalog.py b/tools/refresh_arch_catalog.py index 28af1f2..3663113 100644 --- a/tools/refresh_arch_catalog.py +++ b/tools/refresh_arch_catalog.py @@ -273,14 +273,72 @@ def validate_catalog(): return errors == 0 +def check_links(): + """Check all catalog URLs for broken links (HTTP HEAD requests).""" + data = load_catalog() + entries = data.get("entries", []) + broken = [] + redirected = [] + ok_count = 0 + + print(f"Checking {len(entries)} URLs...") + for i, entry in enumerate(entries): + url = entry.get("url", "") + title = entry.get("title", "UNKNOWN") + if not url: + continue + try: + resp = requests.head(url, timeout=10, allow_redirects=True) + if resp.status_code == 404: + broken.append((title, url)) + print(f" ❌ 404: {title}") + elif resp.status_code in (301, 302) or resp.url != url: + redirected.append((title, url, resp.url)) + print(f" ↪️ Redirect: {title} → {resp.url}") + ok_count += 1 + elif resp.status_code < 400: + ok_count += 1 + else: + broken.append((title, url)) + print(f" ⚠️ {resp.status_code}: {title}") + except requests.RequestException as e: + broken.append((title, url)) + print(f" ⚠️ Error: {title} — {e}") + # Rate limit: 200ms between requests + time.sleep(0.2) + + print(f"\n{'='*60}") + print(f"Results: {ok_count} OK, {len(broken)} broken, {len(redirected)} redirected") + + if broken: + print(f"\n❌ Broken links ({len(broken)}):") + for title, url in broken: + print(f" - {title}") + print(f" {url}") + + if redirected: + print(f"\n↪️ Redirected ({len(redirected)}):") + for title, old, new in redirected: + print(f" - {title}") + print(f" OLD: {old}") + print(f" NEW: {new}") + + return len(broken) == 0 + + def main(): parser = argparse.ArgumentParser(description="Refresh Architecture Center catalog") parser.add_argument("--url", help="Fetch a single URL and print YAML entry") parser.add_argument("--whats-new", action="store_true", help="Crawl What's New pages for new entries") parser.add_argument("--validate", action="store_true", help="Validate existing catalog") + parser.add_argument("--check-links", action="store_true", help="Check all URLs for 404s and redirects") parser.add_argument("--output", default=CATALOG_PATH, help="Output file path") args = parser.parse_args() + if args.check_links: + ok = check_links() + sys.exit(0 if ok else 1) + if args.validate: ok = validate_catalog() sys.exit(0 if ok else 1)