diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5cbe355..b4148ae 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,6 +32,10 @@ jobs: run: git diff --check "$(git hash-object -t tree /dev/null)" HEAD - name: Validate provider catalogs run: node scripts/validate-provider-catalogs.mjs + - name: Validate pinned example templates + env: + STACK_SPECIFICATION_DIR: ${{ github.workspace }}/.stack-specification + run: node scripts/sync-example-templates.mjs --check - name: Install latest stable Rust toolchain run: rustup toolchain install stable --profile minimal --component clippy,rustfmt,llvm-tools-preview - name: Check formatting @@ -42,6 +46,10 @@ jobs: env: STACK_SPECIFICATION_DIR: ${{ github.workspace }}/.stack-specification run: cargo +stable test --features conformance --test formatter-conformance --locked + - name: Run template conformance suite + env: + STACK_SPECIFICATION_DIR: ${{ github.workspace }}/.stack-specification + run: cargo +stable test --features conformance --test template-conformance --locked - name: Run Clippy run: cargo +stable clippy --all-targets --all-features --locked -- -D warnings - name: Build documentation @@ -72,6 +80,7 @@ jobs: ./target/release/stack -V ./target/release/stack --help ./target/release/stack --version + ./target/release/stack init --help ./target/release/stack check --help ./target/release/stack fmt --help ./target/release/stack render --help @@ -94,6 +103,7 @@ jobs: test -s Cargo.lock test -s src/config.rs test -s src/main.rs + test -s src/templates.rs test -s src/provider.rs test -s src/provider_catalog.rs test -s catalogs/aws.json @@ -101,7 +111,10 @@ jobs: test -s catalogs/azure.json test -s catalogs/simple-icons.json test -s scripts/generate-provider-catalogs.mjs + test -s scripts/sync-example-templates.mjs test -s scripts/validate-provider-catalogs.mjs + test -s templates/catalog.json + test -s templates/sources/01-minimal.stack test -s tests/specification-revision test -s tests/fixtures/render.stack diff --git a/Cargo.toml b/Cargo.toml index 0613b96..c3c2077 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,11 @@ name = "formatter-conformance" path = "tests/formatter_conformance.rs" required-features = ["conformance"] +[[test]] +name = "template-conformance" +path = "tests/template_conformance.rs" +required-features = ["conformance"] + [lints.clippy] expect_used = "deny" panic = "deny" diff --git a/README.md b/README.md index 9e589c5..6d29ceb 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ The repository contains native validation, formatting, and rendering commands. T stack help stack help render stack version +stack init +stack init --template groups-and-layout +stack init --template aws-serverless-checkout -o checkout.stack stack check arch.stack stack fmt arch.stack stack fmt --check arch.stack @@ -25,6 +28,8 @@ stack render arch.stack -o arch.svg --notice arch.NOTICE.md `stack help`, `stack -h`, and `stack --help` print top-level help. Use `stack help ` or ` -h` / ` --help` for command-specific usage and examples; nested icon help is available through `stack help icons `. `stack version`, `stack -v`, `stack -V`, and `stack --version` print the same Cargo package version. Help and version output use standard output and exit with status `0`. Invalid arguments and unknown commands use standard error and status `2`; close command typos include a suggested command and the relevant help invocation. +`stack init` creates `diagram.stack` from the versioned `hello-stack` template without prompting. Use `--template ` to select any of the nine curated examples shared with the public Stack specification and Web gallery, and `-o` / `--output` to choose another file. Existing paths are never replaced unless `--force` is explicit; forced writes use the same atomic output behavior as rendering. Provider templates print the exact `stack icons import` commands needed for branded rendering and remain valid with deterministic fallback icons when packs are absent. The embedded catalog and source bytes are pinned by `tests/specification-revision`, and CI rejects drift from that public specification commit. + `stack check` reads the file as bytes and runs the full compiler, theme, layout, and routing validation pipeline without changing the source. Diagnostics are written to standard error in source order. Standard output remains empty. `stack fmt` uses the engine formatter and preserves comments. File mode replaces changed source atomically through a temporary file in the same directory; unchanged files are not replaced. Syntax, encoding, and host I/O failures leave the original file untouched. `stack fmt -` reads bytes from standard input and writes only canonical source to standard output. `--check` never writes source and exits with status `1` when formatting is required. @@ -59,6 +64,17 @@ CI validates formatting, unit and process-level integration tests, at least 90% Canonical formatter behavior is checked against the pinned `stack-sh/specification` fixture revision recorded in `tests/specification-revision`. +The same checkout validates and updates the embedded `stack init` templates: + +```sh +STACK_SPECIFICATION_DIR=../specification \ + node scripts/sync-example-templates.mjs --check +STACK_SPECIFICATION_DIR=../specification \ + node scripts/sync-example-templates.mjs +STACK_SPECIFICATION_DIR=../specification \ + cargo test --features conformance --test template-conformance --locked +``` + See [CONTRIBUTING.md](./CONTRIBUTING.md) before opening a change. Please report security vulnerabilities through the process in [SECURITY.md](./SECURITY.md), not a public issue. ## Licensing diff --git a/scripts/sync-example-templates.mjs b/scripts/sync-example-templates.mjs new file mode 100644 index 0000000..adf4f52 --- /dev/null +++ b/scripts/sync-example-templates.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict" +import { execFile } from "node:child_process" +import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises" +import path from "node:path" +import { promisify } from "node:util" + +const execute = promisify(execFile) +const specificationRootValue = process.env.STACK_SPECIFICATION_DIR +if (!specificationRootValue) { + throw new Error("STACK_SPECIFICATION_DIR must point to the pinned specification checkout") +} + +const checkOnly = process.argv.includes("--check") +const specificationRoot = path.resolve(specificationRootValue) +const expectedRevision = (await readFile("tests/specification-revision", "utf8")).trim() +assert.match(expectedRevision, /^[0-9a-f]{40}$/) +const { stdout } = await execute("git", ["rev-parse", "HEAD"], { + cwd: specificationRoot, + encoding: "utf8", +}) +assert.equal(stdout.trim(), expectedRevision, "Specification checkout does not match the pin") + +const catalogSource = await readFile(path.join(specificationRoot, "examples/catalog.json"), "utf8") +const catalog = JSON.parse(catalogSource) +const sourceNames = catalog.examples.map((example) => example.source).sort() +const templateRoot = path.resolve("templates") +const sourceRoot = path.join(templateRoot, "sources") +const snapshots = new Map([[path.join(templateRoot, "catalog.json"), catalogSource]]) +for (const sourceName of sourceNames) { + snapshots.set( + path.join(sourceRoot, sourceName), + await readFile(path.join(specificationRoot, "examples", sourceName), "utf8"), + ) +} + +if (checkOnly) { + const actualSources = (await readdir(sourceRoot)) + .filter((entry) => entry.endsWith(".stack")) + .sort() + assert.deepEqual(actualSources, sourceNames, "Template source inventory has drifted") + for (const [destination, expected] of snapshots) { + assert.equal( + await readFile(destination, "utf8"), + expected, + `${path.relative(process.cwd(), destination)} has drifted from the pinned specification`, + ) + } + console.log(`Verified ${sourceNames.length} templates against stack-sh/specification@${expectedRevision}.`) +} else { + await mkdir(sourceRoot, { recursive: true }) + for (const entry of await readdir(sourceRoot)) { + if (entry.endsWith(".stack") && !sourceNames.includes(entry)) { + await rm(path.join(sourceRoot, entry)) + } + } + for (const [destination, contents] of snapshots) { + await mkdir(path.dirname(destination), { recursive: true }) + await writeFile(destination, contents) + } + console.log(`Synchronized ${sourceNames.length} templates from stack-sh/specification@${expectedRevision}.`) +} diff --git a/src/lib.rs b/src/lib.rs index 52ce5dc..0160c20 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,7 @@ use stack_engine::{ mod config; mod provider; mod provider_catalog; +mod templates; /// Exit status used when a command completes without Stack error diagnostics. pub const EXIT_SUCCESS: u8 = 0; @@ -33,6 +34,7 @@ Usage: stack help [COMMAND] Commands: + init Create a Stack file from a versioned starter template check Validate a Stack source file without modifying it fmt Format a file in place or read from standard input render Render standalone SVG to standard output or a file @@ -45,11 +47,46 @@ Options: -v, -V, --version Print version Examples: + stack init + stack init --template application-and-data -o architecture.stack stack check arch.stack stack fmt --check arch.stack stack render arch.stack -o arch.svg stack icons list aws s3 "; +const INIT_HELP: &str = "\ +Create a Stack file from a versioned starter template + +Usage: + stack init [--template