diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb43da2..f67fb36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,11 @@ jobs: - name: Validate JSON Schemas run: check-jsonschema --check-metaschema schemas/*.json + - name: Validate example catalog + run: | + check-jsonschema --schemafile schemas/example-catalog.schema.json examples/catalog.json + python scripts/validate-example-catalog.py + - name: Validate normalized IR fixtures run: check-jsonschema --schemafile schemas/normalized-ir.schema.json conformance/valid/*/expected.ir.json conformance/formatter/*/expected.ir.json diff --git a/README.md b/README.md index a84839b..370a72a 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,10 @@ Install the development requirements and validate the portable schemas and confo ```sh python -m pip install --requirement requirements-dev.txt check-jsonschema --check-metaschema schemas/*.json +check-jsonschema --schemafile schemas/example-catalog.schema.json examples/catalog.json check-jsonschema --schemafile schemas/normalized-ir.schema.json conformance/valid/*/expected.ir.json conformance/formatter/*/expected.ir.json find conformance -name expected.diagnostics.json -print0 | xargs -0 check-jsonschema --schemafile schemas/diagnostic-expectations.schema.json +python scripts/validate-example-catalog.py python scripts/validate-compiler-diagnostics.py python scripts/validate-formatter-fixtures.py npm ci diff --git a/examples/02-node-semantics.stack b/examples/02-node-semantics.stack index b28e806..d27417b 100644 --- a/examples/02-node-semantics.stack +++ b/examples/02-node-semantics.stack @@ -5,13 +5,13 @@ diagram "Application and datastore" { node app "Application" { kind service - icon "service" + icon "server" detail "Business logic" } node db "Primary database" { kind database - icon "postgresql" + icon "database" detail "PostgreSQL" } diff --git a/examples/03-groups-and-layout.stack b/examples/03-groups-and-layout.stack index b2310aa..6e8c2f6 100644 --- a/examples/03-groups-and-layout.stack +++ b/examples/03-groups-and-layout.stack @@ -14,7 +14,7 @@ diagram "Public application" { node browser "Browser" { kind client - icon "browser" + icon "web" } node mobile "Mobile app" { diff --git a/examples/04-commerce-platform.stack b/examples/04-commerce-platform.stack index b3a5029..5231503 100644 --- a/examples/04-commerce-platform.stack +++ b/examples/04-commerce-platform.stack @@ -12,7 +12,7 @@ diagram "Commerce platform" { group storefront "Storefront" { node web "Web storefront" { kind client - icon "nextjs" + icon "web" detail "Next.js" } @@ -54,12 +54,12 @@ diagram "Commerce platform" { group data "Data" { node products "Product database" { kind database - icon "postgresql" + icon "database" } node orders "Order database" { kind database - icon "postgresql" + icon "database" } node assets "Product media" { diff --git a/examples/05-aws-serverless.stack b/examples/05-aws-serverless.stack new file mode 100644 index 0000000..81e8371 --- /dev/null +++ b/examples/05-aws-serverless.stack @@ -0,0 +1,76 @@ +stack 1.0 + +diagram "AWS serverless checkout" { + theme light + + layout { + direction right + } + + node customer "Customer" { + kind actor + } + + group edge "Edge" { + node cdn "CloudFront" { + kind service + icon "aws:amazon-cloudfront" + } + + node gateway "API Gateway" { + kind service + icon "aws:amazon-api-gateway" + } + } + + group compute "Serverless compute" { + node checkout "Checkout" { + kind function + icon "aws:lambda" + } + + node events "Order events" { + kind queue + icon "aws:amazon-eventbridge" + } + + node fulfillment "Fulfillment" { + kind worker + icon "aws:lambda" + } + } + + group data "Data" { + node orders "Orders" { + kind database + icon "aws:dynamodb" + } + + node receipts "Receipts" { + kind storage + icon "aws:s3" + } + } + + edge customer -> cdn "HTTPS" { + kind request + } + edge cdn -> gateway "API request" { + kind request + } + edge gateway -> checkout "Invoke" { + kind request + } + edge checkout -> orders "Write order" { + kind data + } + edge checkout -> events "OrderPlaced" { + kind event + } + edge events -> fulfillment "Dispatch" { + kind event + } + edge fulfillment -> receipts "Archive" { + kind data + } +} diff --git a/examples/06-gcp-data-service.stack b/examples/06-gcp-data-service.stack new file mode 100644 index 0000000..cac7939 --- /dev/null +++ b/examples/06-gcp-data-service.stack @@ -0,0 +1,58 @@ +stack 1.0 + +diagram "GCP data service" { + theme light + + layout { + direction right + } + + node client "Web client" { + kind client + icon "web" + } + + group application "Application" { + node api "Cloud Run API" { + kind service + icon "gcp:cloud-run" + } + + node database "Cloud SQL" { + kind database + icon "gcp:cloud-sql" + } + + node objects "Cloud Storage" { + kind storage + icon "gcp:cloud-storage" + } + } + + group analytics "Analytics" { + node warehouse "BigQuery" { + kind database + icon "gcp:bigquery" + } + + node analyst "Analyst" { + kind actor + } + } + + edge client -> api "HTTPS" { + kind request + } + edge api -> database "SQL" { + kind data + } + edge api -> objects "Uploads" { + kind data + } + edge api -> warehouse "Events" { + kind event + } + edge analyst -> warehouse "Queries" { + kind request + } +} diff --git a/examples/07-azure-event-platform.stack b/examples/07-azure-event-platform.stack new file mode 100644 index 0000000..87b5941 --- /dev/null +++ b/examples/07-azure-event-platform.stack @@ -0,0 +1,76 @@ +stack 1.0 + +diagram "Azure event platform" { + theme light + + layout { + direction right + } + + node customer "Customer" { + kind actor + } + + group delivery "Delivery" { + node frontdoor "Front Door" { + kind service + icon "azure:front-door-and-cdn-profiles" + } + + node app "App Service" { + kind service + icon "azure:app-service" + } + } + + group processing "Event processing" { + node ingest "Ingest function" { + kind function + icon "azure:function-apps" + } + + node bus "Service Bus" { + kind queue + icon "azure:azure-service-bus" + } + + node worker "Background function" { + kind worker + icon "azure:function-apps" + } + } + + group data "Data" { + node database "Cosmos DB" { + kind database + icon "azure:azure-cosmos-db" + } + + node archive "Storage account" { + kind storage + icon "azure:storage-accounts" + } + } + + edge customer -> frontdoor "HTTPS" { + kind request + } + edge frontdoor -> app "Route" { + kind request + } + edge app -> ingest "Submit" { + kind request + } + edge ingest -> bus "Work item" { + kind event + } + edge bus -> worker "Dispatch" { + kind event + } + edge worker -> database "Persist" { + kind data + } + edge worker -> archive "Archive" { + kind data + } +} diff --git a/examples/08-github-delivery.stack b/examples/08-github-delivery.stack new file mode 100644 index 0000000..f24bbd9 --- /dev/null +++ b/examples/08-github-delivery.stack @@ -0,0 +1,50 @@ +stack 1.0 + +diagram "GitHub delivery workflow" { + theme dark + + layout { + direction right + } + + node developer "Developer" { + kind actor + } + + group source "Source" { + node github "GitHub repository" { + kind service + icon "simple-icons:github" + } + + node actions "GitHub Actions" { + kind worker + icon "simple-icons:githubactions" + } + } + + group delivery "Delivery" { + node web "Vercel" { + kind external + icon "simple-icons:vercel" + } + + node errors "Sentry" { + kind external + icon "simple-icons:sentry" + } + } + + edge developer -> github "Push" { + kind data + } + edge github -> actions "Workflow" { + kind event + } + edge actions -> web "Deploy" { + kind dependency + } + edge web -> errors "Report errors" { + kind event + } +} diff --git a/examples/09-mixed-provider-platform.stack b/examples/09-mixed-provider-platform.stack new file mode 100644 index 0000000..6f464c9 --- /dev/null +++ b/examples/09-mixed-provider-platform.stack @@ -0,0 +1,80 @@ +stack 1.0 + +diagram "Mixed-provider platform" { + theme light + + layout { + direction right + } + + node customer "Customer" { + kind actor + } + + group platform "Platform" { + layout { + direction down + } + + group experience "Experience" { + node web "Web application" { + kind client + icon "simple-icons:vercel" + } + + node api "Public API" { + kind service + icon "gcp:cloud-run" + } + } + + group processing "Processing" { + layout { + direction down + rank same [checkout, events] + order [checkout, events] + } + + node checkout "Checkout" { + kind function + icon "aws:lambda" + } + + node events "Order events" { + kind queue + icon "azure:azure-service-bus" + } + } + + group operations "Operations" { + node database "Orders" { + kind database + icon "simple-icons:postgresql" + } + + node monitoring "Error monitoring" { + kind external + icon "simple-icons:sentry" + } + } + } + + edge customer -> web "HTTPS" { + kind request + } + edge web <-> api "Live checkout" { + kind flow + } + edge api -> checkout "Invoke" { + kind dependency + } + edge checkout -> events "OrderPlaced" { + kind event + } + edge checkout -> database "Transaction" { + kind data + } + edge api -- monitoring "Telemetry" { + kind data + } +} diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..ab5ba51 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,15 @@ +# Stack example catalog + +[`catalog.json`](./catalog.json) is the versioned index for the curated Stack example corpus. Its contract is defined by [`example-catalog.schema.json`](../schemas/example-catalog.schema.json). + +Each entry links to one canonical `.stack` source and records: + +- the learning stage and intended use; +- required caller-owned provider icon packs; +- represented syntax features; +- the expected node, group, and edge structure; +- accessible alternative text for a generated thumbnail. + +Consumers such as the Web gallery, CLI starter templates, layout fixtures, and machine-readable distribution should pin a specification commit and reuse these sources. A consumer may keep a generated snapshot for a hermetic build, but it must verify every copied source against the pinned catalog rather than maintaining an independent example. + +Provider names identify caller-owned packs. The corpus contains provider icon identifiers, not provider artwork, licenses, or terms acceptance. Consumers must resolve those identifiers through validated local packs and preserve the provider boundary defined by the specification. diff --git a/examples/catalog.json b/examples/catalog.json new file mode 100644 index 0000000..095e3bc --- /dev/null +++ b/examples/catalog.json @@ -0,0 +1,245 @@ +{ + "$schema": "../schemas/example-catalog.schema.json", + "schemaVersion": "1.0", + "languageVersion": "1.0", + "examples": [ + { + "id": "hello-stack", + "title": "Hello Stack", + "summary": "Start with two nodes and one directed edge.", + "learningStage": "starter", + "providers": [], + "features": ["directed-edges"], + "source": "01-minimal.stack", + "expected": { + "description": "A web app points to an API in one compact row.", + "nodes": 2, + "groups": 0, + "edges": 1 + }, + "thumbnail": { + "alt": "A minimal diagram with Web app connected to API" + } + }, + { + "id": "application-and-data", + "title": "Application and data", + "summary": "Add semantic node kinds, icons, details, an edge label, and a light theme.", + "learningStage": "starter", + "providers": [], + "features": [ + "themes", + "node-kinds", + "node-details", + "edge-labels", + "edge-kinds", + "directed-edges" + ], + "source": "02-node-semantics.stack", + "expected": { + "description": "An application sends SQL data to a PostgreSQL database.", + "nodes": 2, + "groups": 0, + "edges": 1 + }, + "thumbnail": { + "alt": "Application and Primary database nodes connected by a SQL edge" + } + }, + { + "id": "groups-and-layout", + "title": "Groups and layout", + "summary": "Organize clients and platform services with explicit direction, rank, and order hints.", + "learningStage": "intermediate", + "providers": [], + "features": [ + "node-kinds", + "groups", + "layout-direction", + "rank-constraints", + "order-constraints", + "edge-labels", + "edge-kinds", + "directed-edges" + ], + "source": "03-groups-and-layout.stack", + "expected": { + "description": "Browser and mobile clients converge on a gateway before the API and database.", + "nodes": 5, + "groups": 2, + "edges": 4 + }, + "thumbnail": { + "alt": "Client and Platform groups connected through an Edge gateway" + } + }, + { + "id": "commerce-platform", + "title": "Commerce platform", + "summary": "Model a production-like commerce system with nested responsibilities and asynchronous processing.", + "learningStage": "advanced", + "providers": [], + "features": [ + "node-kinds", + "node-details", + "groups", + "layout-direction", + "rank-constraints", + "order-constraints", + "edge-labels", + "edge-kinds", + "directed-edges" + ], + "source": "04-commerce-platform.stack", + "expected": { + "description": "Storefront, commerce, processing, data, and partner groups show a full order flow.", + "nodes": 13, + "groups": 5, + "edges": 12 + }, + "thumbnail": { + "alt": "A production commerce architecture from customer through storefront, services, data, and partners" + } + }, + { + "id": "aws-serverless-checkout", + "title": "AWS serverless checkout", + "summary": "Follow a checkout request through AWS edge, serverless compute, events, and storage services.", + "learningStage": "intermediate", + "providers": ["aws"], + "features": [ + "themes", + "node-kinds", + "groups", + "layout-direction", + "edge-labels", + "edge-kinds", + "directed-edges", + "provider-icons" + ], + "source": "05-aws-serverless.stack", + "expected": { + "description": "AWS edge and Lambda services emit an order event before persisting receipts.", + "nodes": 8, + "groups": 3, + "edges": 7 + }, + "thumbnail": { + "alt": "AWS checkout architecture with Edge, Serverless compute, and Data groups" + } + }, + { + "id": "gcp-data-service", + "title": "GCP data service", + "summary": "Connect Cloud Run to transactional, object, and analytical data stores.", + "learningStage": "intermediate", + "providers": ["gcp"], + "features": [ + "themes", + "node-kinds", + "groups", + "layout-direction", + "edge-labels", + "edge-kinds", + "directed-edges", + "provider-icons" + ], + "source": "06-gcp-data-service.stack", + "expected": { + "description": "A Cloud Run API serves a web client and feeds Cloud SQL, Cloud Storage, and BigQuery.", + "nodes": 6, + "groups": 2, + "edges": 5 + }, + "thumbnail": { + "alt": "GCP application and analytics groups connected to a web client" + } + }, + { + "id": "azure-event-platform", + "title": "Azure event platform", + "summary": "Trace asynchronous work from Azure delivery services through functions, Service Bus, and data stores.", + "learningStage": "intermediate", + "providers": ["azure"], + "features": [ + "themes", + "node-kinds", + "groups", + "layout-direction", + "edge-labels", + "edge-kinds", + "directed-edges", + "provider-icons" + ], + "source": "07-azure-event-platform.stack", + "expected": { + "description": "Front Door and App Service hand work to functions and Service Bus before storage.", + "nodes": 8, + "groups": 3, + "edges": 7 + }, + "thumbnail": { + "alt": "Azure delivery, event processing, and data groups in a left-to-right flow" + } + }, + { + "id": "github-delivery-workflow", + "title": "GitHub delivery workflow", + "summary": "Describe a SaaS delivery path from source control and CI to hosting and error monitoring.", + "learningStage": "intermediate", + "providers": ["simple-icons"], + "features": [ + "themes", + "node-kinds", + "groups", + "layout-direction", + "edge-labels", + "edge-kinds", + "directed-edges", + "provider-icons" + ], + "source": "08-github-delivery.stack", + "expected": { + "description": "A developer push triggers GitHub Actions, deployment, and error reporting.", + "nodes": 5, + "groups": 2, + "edges": 4 + }, + "thumbnail": { + "alt": "GitHub repository and Actions connected to Vercel and Sentry" + } + }, + { + "id": "mixed-provider-platform", + "title": "Mixed-provider platform", + "summary": "Combine AWS, GCP, Azure, and SaaS services while preserving semantic kinds and portable flow.", + "learningStage": "advanced", + "providers": ["aws", "gcp", "azure", "simple-icons"], + "features": [ + "themes", + "node-kinds", + "groups", + "nested-groups", + "layout-direction", + "rank-constraints", + "order-constraints", + "edge-labels", + "edge-kinds", + "directed-edges", + "bidirectional-edges", + "association-edges", + "provider-icons" + ], + "source": "09-mixed-provider-platform.stack", + "expected": { + "description": "A web client crosses provider boundaries for API, processing, events, data, and monitoring.", + "nodes": 7, + "groups": 4, + "edges": 6 + }, + "thumbnail": { + "alt": "A mixed-provider platform spanning Vercel, GCP, AWS, Azure, PostgreSQL, and Sentry" + } + } + ] +} diff --git a/schemas/example-catalog.schema.json b/schemas/example-catalog.schema.json new file mode 100644 index 0000000..a553814 --- /dev/null +++ b/schemas/example-catalog.schema.json @@ -0,0 +1,134 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Stack Example Catalog", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "schemaVersion", "languageVersion", "examples"], + "properties": { + "$schema": { + "const": "../schemas/example-catalog.schema.json" + }, + "schemaVersion": { + "const": "1.0" + }, + "languageVersion": { + "const": "1.0" + }, + "examples": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/example" + } + } + }, + "$defs": { + "example": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "title", + "summary", + "learningStage", + "providers", + "features", + "source", + "expected", + "thumbnail" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,63}$" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": 180 + }, + "learningStage": { + "enum": ["starter", "intermediate", "advanced"] + }, + "providers": { + "type": "array", + "items": { + "enum": ["aws", "gcp", "azure", "simple-icons"] + }, + "uniqueItems": true + }, + "features": { + "type": "array", + "minItems": 1, + "items": { + "enum": [ + "themes", + "node-kinds", + "node-details", + "groups", + "nested-groups", + "layout-direction", + "rank-constraints", + "order-constraints", + "edge-labels", + "edge-kinds", + "directed-edges", + "bidirectional-edges", + "association-edges", + "provider-icons" + ] + }, + "uniqueItems": true + }, + "source": { + "type": "string", + "pattern": "^[0-9]{2}-[a-z0-9-]+\\.stack$" + }, + "expected": { + "type": "object", + "additionalProperties": false, + "required": ["description", "nodes", "groups", "edges"], + "properties": { + "description": { + "type": "string", + "minLength": 1, + "maxLength": 180 + }, + "nodes": { + "type": "integer", + "minimum": 1, + "maximum": 40 + }, + "groups": { + "type": "integer", + "minimum": 0, + "maximum": 12 + }, + "edges": { + "type": "integer", + "minimum": 0, + "maximum": 80 + } + } + }, + "thumbnail": { + "type": "object", + "additionalProperties": false, + "required": ["alt"], + "properties": { + "alt": { + "type": "string", + "minLength": 1, + "maxLength": 180 + } + } + } + } + } + } +} diff --git a/scripts/validate-example-catalog.py b/scripts/validate-example-catalog.py new file mode 100644 index 0000000..68b1458 --- /dev/null +++ b/scripts/validate-example-catalog.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 + +import json +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +EXAMPLES = ROOT / "examples" +CATALOG_PATH = EXAMPLES / "catalog.json" + + +def count_declarations(source: str, declaration: str) -> int: + return len(re.findall(rf"^\s*{declaration}\s+", source, re.MULTILINE)) + + +def represented_features(source: str) -> set[str]: + features = set() + patterns = { + "themes": r"^\s*theme\s+", + "node-kinds": r"^\s*node\s+[^\n{]+\{[^{}]*^\s*kind\s+", + "node-details": r"^\s*detail\s+", + "groups": r"^\s*group\s+", + "layout-direction": r"^\s*direction\s+", + "rank-constraints": r"^\s*rank\s+same\s+", + "order-constraints": r"^\s*order\s+", + "edge-labels": r'^\s*edge\s+[^\n]+\s+"[^\n]+"\s*\{', + "edge-kinds": r"^\s*edge\s+[^\n{]+\{[^{}]*^\s*kind\s+", + "directed-edges": r"^\s*edge\s+\S+\s+->\s+\S+", + "bidirectional-edges": r"^\s*edge\s+\S+\s+<->\s+\S+", + "association-edges": r"^\s*edge\s+\S+\s+--\s+\S+", + "provider-icons": r'^\s*icon\s+"[a-z][a-z0-9-]+:', + } + for feature, pattern in patterns.items(): + if re.search(pattern, source, re.MULTILINE | re.DOTALL): + features.add(feature) + + depth = 0 + for line in source.splitlines(): + if re.match(r"^\s*group\s+", line) and depth > 1: + features.add("nested-groups") + depth += line.count("{") - line.count("}") + return features + + +catalog = json.loads(CATALOG_PATH.read_text(encoding="utf-8")) +entries = catalog["examples"] + +ids = [entry["id"] for entry in entries] +sources = [entry["source"] for entry in entries] +if len(ids) != len(set(ids)): + raise SystemExit("example catalog contains a duplicate id") +if len(sources) != len(set(sources)): + raise SystemExit("example catalog contains a duplicate source") + +actual_sources = sorted(path.name for path in EXAMPLES.glob("*.stack")) +if sorted(sources) != actual_sources: + raise SystemExit("example catalog does not contain every .stack source exactly once") + +providers = set() +features = set() +stages = set() +for entry in entries: + source_path = EXAMPLES / entry["source"] + if source_path.parent != EXAMPLES: + raise SystemExit(f"{entry['id']} source escapes the examples directory") + source = source_path.read_text(encoding="utf-8") + if not source.startswith("stack 1.0\n\n") or not source.endswith("\n"): + raise SystemExit(f"{entry['source']} must be a canonical Stack 1.0 text file") + + expected = entry["expected"] + actual = { + "nodes": count_declarations(source, "node"), + "groups": count_declarations(source, "group"), + "edges": count_declarations(source, "edge"), + } + for field, count in actual.items(): + if expected[field] != count: + raise SystemExit( + f"{entry['id']} expects {expected[field]} {field}, found {count}" + ) + + referenced_providers = set(re.findall(r'icon "([a-z][a-z0-9-]+):', source)) + if referenced_providers != set(entry["providers"]): + raise SystemExit( + f"{entry['id']} provider metadata does not match its icon identifiers" + ) + if bool(referenced_providers) != ("provider-icons" in entry["features"]): + raise SystemExit(f"{entry['id']} provider-icons feature metadata is inconsistent") + + represented = represented_features(source) + if represented != set(entry["features"]): + missing = sorted(represented - set(entry["features"])) + extra = sorted(set(entry["features"]) - represented) + raise SystemExit( + f"{entry['id']} feature metadata mismatch: missing={missing}, extra={extra}" + ) + + providers.update(entry["providers"]) + features.update(entry["features"]) + stages.add(entry["learningStage"]) + +required_providers = {"aws", "gcp", "azure", "simple-icons"} +if providers != required_providers: + raise SystemExit("example catalog does not cover every supported provider namespace") +if stages != {"starter", "intermediate", "advanced"}: + raise SystemExit("example catalog does not cover every learning stage") + +required_features = { + "themes", + "node-kinds", + "node-details", + "groups", + "nested-groups", + "layout-direction", + "rank-constraints", + "order-constraints", + "edge-labels", + "edge-kinds", + "directed-edges", + "bidirectional-edges", + "association-edges", + "provider-icons", +} +if features != required_features: + missing = sorted(required_features - features) + extra = sorted(features - required_features) + raise SystemExit(f"example feature coverage mismatch: missing={missing}, extra={extra}") + +print( + f"Validated {len(entries)} Stack examples across {len(stages)} learning stages " + f"and {len(providers)} provider namespaces." +)