From 16af1cbe83a0929879807abe39c49102aa706f42 Mon Sep 17 00:00:00 2001 From: Brandon Bayer Date: Fri, 24 Jul 2026 17:05:44 -0400 Subject: [PATCH] fix module section heading collisions --- .../rvn-ec2-service-definition.yml | 6 ++-- .../rvn-ecs-cluster-definition.yml | 6 ++-- compute/lambda/rvn-lambda-definition.yml | 6 ++-- .../static_site/rvn-aws-static-definition.yml | 6 ++-- tools/ravion-modules/src/compiler.ts | 3 +- tools/ravion-modules/src/guardrails.ts | 36 +++++++++++++++++++ tools/ravion-modules/test/guardrails.test.ts | 32 +++++++++++++++++ 7 files changed, 82 insertions(+), 13 deletions(-) diff --git a/compute/ec2_service/rvn-ec2-service-definition.yml b/compute/ec2_service/rvn-ec2-service-definition.yml index 3eb5528f..2b90559b 100644 --- a/compute/ec2_service/rvn-ec2-service-definition.yml +++ b/compute/ec2_service/rvn-ec2-service-definition.yml @@ -3,8 +3,8 @@ definition: name: EC2 Service description: Runs supervised workloads on a stable EC2 Auto Scaling Group, with optional shared ALB routing and switchable container or manual in-place deploys. release: - version: 0.1.1 - description: Reorganize EC2 service inputs and clarify web and storage configuration. + version: 0.1.2 + description: Clarify the EC2 service configuration section label. module: inputs: - id: network @@ -24,7 +24,7 @@ module: required: true - $include: ../../partials/inputs/private-subnet-placement.yml - id: section_service - label: EC2 service + label: EC2 service config type: section - id: name immutable: true diff --git a/compute/ecs_cluster/rvn-ecs-cluster-definition.yml b/compute/ecs_cluster/rvn-ecs-cluster-definition.yml index bb663e70..f28a143a 100644 --- a/compute/ecs_cluster/rvn-ecs-cluster-definition.yml +++ b/compute/ecs_cluster/rvn-ecs-cluster-definition.yml @@ -3,8 +3,8 @@ definition: name: ECS Cluster description: Production-ready AWS ECS cluster with Fargate, Fargate Spot, optional EC2 capacity, and shared load balancers. release: - version: 0.3.1 - description: Add additional ALB certificate ARN fields for SNI. + version: 0.3.2 + description: Clarify the ECS cluster configuration section label. module: inputs: - id: network @@ -22,7 +22,7 @@ module: required: true type: $ref:rvn-aws-network - id: section_cluster - label: ECS cluster + label: ECS cluster config type: section - default: <>-<> description: Name prefix for all resources. Terraform requires 1-28 characters so generated ALB names fit AWS limits. diff --git a/compute/lambda/rvn-lambda-definition.yml b/compute/lambda/rvn-lambda-definition.yml index eeab0cb0..26c79605 100644 --- a/compute/lambda/rvn-lambda-definition.yml +++ b/compute/lambda/rvn-lambda-definition.yml @@ -3,14 +3,14 @@ definition: name: Lambda Function description: AWS Lambda function with zip or container image deployments, alias-based releases, IAM, logs, and optional function URLs. release: - version: 0.3.2 - description: Improve build source and builder instance guidance. + version: 0.3.3 + description: Clarify the Lambda function configuration section label. module: inputs: - $include: ../../partials/inputs/aws-account.yml - $include: ../../partials/inputs/aws-region.yml - id: section_function - label: Lambda function + label: Lambda function config type: section - id: lambda_type immutable: true diff --git a/hosting/static_site/rvn-aws-static-definition.yml b/hosting/static_site/rvn-aws-static-definition.yml index 25a8da68..af2e6b19 100644 --- a/hosting/static_site/rvn-aws-static-definition.yml +++ b/hosting/static_site/rvn-aws-static-definition.yml @@ -3,8 +3,8 @@ definition: name: Static Hosting description: Static file hosting for S3-backed sites and assets delivered through CloudFront. release: - version: 0.3.4 - description: Improve build source, Railpack command, and builder instance guidance. + version: 0.3.5 + description: Clarify the static hosting configuration section label. module: build: builder: >- @@ -54,7 +54,7 @@ module: default: us-east-1 description: Region for the S3 bucket. CloudFront and KVS always use us-east-1. - id: section_hosting - label: Static hosting + label: Static hosting config type: section - default: <>-<>-<> description: Name for the bucket and other resources' prefix. Must be globally unique. diff --git a/tools/ravion-modules/src/compiler.ts b/tools/ravion-modules/src/compiler.ts index 2fece002..04a8f18a 100644 --- a/tools/ravion-modules/src/compiler.ts +++ b/tools/ravion-modules/src/compiler.ts @@ -2,7 +2,7 @@ import { readdir, readFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import YAML from "yaml"; import { parseAuthoringDefinitionFile, type AuthoringDefinition } from "./authoring-schema.js"; -import { validateUniqueDefinitionTypes } from "./guardrails.js"; +import { validateSectionLabelsDoNotCollideWithDefinitionNames, validateUniqueDefinitionTypes } from "./guardrails.js"; export interface CompiledDefinition { filePath: string; @@ -78,6 +78,7 @@ export async function compileAllDefinitions(rootPath = process.cwd()): Promise compileDefinitionFile(filePath))); const sorted = compiled.sort((left, right) => left.filePath.localeCompare(right.filePath)); validateUniqueDefinitionTypes(sorted); + validateSectionLabelsDoNotCollideWithDefinitionNames(sorted); return sorted; } diff --git a/tools/ravion-modules/src/guardrails.ts b/tools/ravion-modules/src/guardrails.ts index 861e5461..6953b45b 100644 --- a/tools/ravion-modules/src/guardrails.ts +++ b/tools/ravion-modules/src/guardrails.ts @@ -53,6 +53,34 @@ export function validateUniqueDefinitionTypes(definitions: CompiledDefinition[]) } } +export function validateSectionLabelsDoNotCollideWithDefinitionNames(definitions: CompiledDefinition[]): void { + const collisions: string[] = []; + + for (const definition of definitions) { + const inputs = definition.module.inputs; + if (!Array.isArray(inputs)) { + continue; + } + + const definitionSlug = slugifyHeading(definition.name); + for (const input of inputs) { + if (!isRecord(input) || input.type !== "section" || typeof input.label !== "string") { + continue; + } + + if (slugifyHeading(input.label) === definitionSlug) { + collisions.push(`${definition.type}: section "${input.label}" in ${definition.filePath}`); + } + } + } + + if (collisions.length > 0) { + throw new GuardrailError( + `Section labels must not generate the same anchor as their module definition name:\n${collisions.map((collision) => `- ${collision}`).join("\n")}`, + ); + } +} + async function findLegacyModuleDefinitionFiles(rootPath: string): Promise { const yamlFiles: string[] = []; await collectYamlFiles(rootPath, rootPath, yamlFiles); @@ -144,6 +172,14 @@ function normalizeRelativePath(rootPath: string, filePath: string): string { return relative(rootPath, filePath).split(sep).join("/"); } +function slugifyHeading(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9_\s-]/g, "") + .replace(/\s+/g, "-"); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/tools/ravion-modules/test/guardrails.test.ts b/tools/ravion-modules/test/guardrails.test.ts index 61438f28..b870c18f 100644 --- a/tools/ravion-modules/test/guardrails.test.ts +++ b/tools/ravion-modules/test/guardrails.test.ts @@ -56,6 +56,38 @@ describe("migration guardrails", () => { (error) => error instanceof GuardrailError && error.message.includes("Duplicate definition.type") && error.message.includes("ravion-aws-network"), ); }); + + it("fails when a section label generates the same anchor as the definition name", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "ravion-section-label-")); + const modulePath = join(rootPath, "compute", "ec2_service"); + await mkdir(modulePath, { recursive: true }); + await writeFile( + join(modulePath, "rvn-ec2-service-definition.yml"), + [ + "definition:", + " type: rvn-ec2-service", + " name: EC2 Service", + " description: Test module", + "release:", + " version: 1.0.0", + " description: Initial release.", + "module:", + " inputs:", + " - id: section_service", + " label: EC2 service", + " type: section", + "", + ].join("\n"), + ); + + await assert.rejects( + compileAllDefinitions(rootPath), + (error) => + error instanceof GuardrailError && + error.message.includes("Section labels must not generate the same anchor") && + error.message.includes("rvn-ec2-service"), + ); + }); }); async function writeDefinition(rootPath: string, category: string, moduleName: string, type: string): Promise {