import { Annotations, CfnResource, IAspect, Stack, Token } from "aws-cdk-lib"; import { IConstruct } from "constructs"; /** * One "/"-delimited segment must be lower kebab-case: `a-b-c`, digits allowed. */ const KEBAB_SEGMENT = /^[a-z0-9]+(-[a-z0-9]+)*$/; /** * CloudFormation property keys that carry an *explicit physical name*. The * codegen'd L1 stores these either camelCased (`roleName`) or CFN-cased * (`RoleName`) depending on the construct, so the aspect matches keys * case-insensitively. * * We deliberately validate physical NAMES + the stack name only — not CDK * logical construct ids (those are conventionally PascalCase, e.g. * `InfraDeployRole`, and validating them would be wrong). */ const NAME_PROPERTY_KEYS = [ "RoleName", "BucketName", "FunctionName", "TableName", "LogGroupName", "QueueName", "TopicName", "SecretName", "StreamName", "RepositoryName", "DBInstanceIdentifier", "DBClusterIdentifier", "StateMachineName", "RuleName", "UserPoolName", ]; // NOTE: `PolicyName` is intentionally NOT checked — CDK auto-generates inline // `DefaultPolicy` names (e.g. "InstanceRoleDefaultPolicyF15F...") from the // logical id; those are not explicit, user-controlled physical names and are // outside the naming convention's scope. const NAME_KEYS_LC = new Set(NAME_PROPERTY_KEYS.map((k) => k.toLowerCase())); /** * Resource types whose physical NAME is a REQUIRED deviation from kebab-case: * the open-swe config store names secrets `open-swe-/` and SSM * params `/open-swe-/`, where the last segment is the LITERAL * UPPER_SNAKE environment-variable name. The boot hook * (deploy/seahaven/fetch-config.sh) strips the prefix and exports that segment * verbatim, so a lossless store→env round-trip needs the exact env-var name — * it cannot be kebab-cased. These two resource types are therefore exempt; every * OTHER explicitly-named resource is still validated. (The `open-swe-` * prefix is code-generated from `prefix(env)` and is always kebab-case.) */ const KEBAB_EXEMPT_RESOURCE_TYPES = new Set([ "AWS::SecretsManager::Secret", "AWS::SSM::Parameter", ]); /** * `true` when every non-empty "/"-delimited segment is kebab-case. * * Path-style names are tolerated so the same check works for Secrets Manager * (`open-swe-dev/foo`), SSM params (`/open-swe-dev/foo`) and log groups * (`/open-swe/dev/agent`): each segment is validated independently, and a * leading slash (empty first segment) is ignored. */ export function isKebabCase(value: string): boolean { return value .split("/") .filter((seg) => seg.length > 0) .every((seg) => KEBAB_SEGMENT.test(seg)); } /** * Aspect that FAILS synth (`Annotations.addError`) when an explicitly-named * resource — or a stack name — is not kebab-case. Enforces the org naming * convention (naming-conventions.md) deterministically at synth time so a * non-conforming name can never reach a deploy. Wired in bin/app.ts via * `Aspects.of(app).add(new KebabNamingAspect())`. */ export class KebabNamingAspect implements IAspect { public visit(node: IConstruct): void { if (node instanceof Stack) { const name = node.stackName; if (!Token.isUnresolved(name) && !isKebabCase(name)) { Annotations.of(node).addError( `Stack name "${name}" is not kebab-case (open-swe naming convention).`, ); } return; } if (node instanceof CfnResource) { // The config store's Secret/Parameter names carry the literal UPPER_SNAKE // env-var name per the fetch-config naming contract — a required deviation. if (KEBAB_EXEMPT_RESOURCE_TYPES.has(node.cfnResourceType)) { return; } // `_cfnProperties` is the props as set on the L1; resolve to collapse any // intrinsic tokens (refs/getatt) so only literal strings are checked. // eslint-disable-next-line @typescript-eslint/no-explicit-any const raw = (node as any)._cfnProperties ?? {}; const resolved = Stack.of(node).resolve(raw) ?? {}; for (const [key, value] of Object.entries(resolved)) { if ( NAME_KEYS_LC.has(key.toLowerCase()) && typeof value === "string" && !Token.isUnresolved(value) && !isKebabCase(value) ) { Annotations.of(node).addError( `Resource "${node.node.path}" property ${key}="${value}" is not kebab-case ` + `(open-swe naming convention).`, ); } } } } }