From eb532050fd8b4f9fb50439f6e896b86382dfff82 Mon Sep 17 00:00:00 2001 From: Ari Palo <679146+aripalo@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:51:08 +0300 Subject: [PATCH] feat: support packages without scope --- README.md | 2 +- src/AlmaCdkConstructLibrary.ts | 14 ++++-- src/SonarCloudReportWorkflow.ts | 30 ++++++++--- src/schemas/almaCdkConstructLibraryOptions.ts | 2 +- src/schemas/name.ts | 50 +++++++++++++++---- test/AlmaCdkConstructLibrary.test.ts | 44 +++++++++++++++- .../almaCdkConstructLibraryOptions.test.ts | 11 +++- test/schemas/name.test.ts | 35 ++++++++++++- 8 files changed, 163 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 11ec187..f5dd264 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Custom [Projen Project Type](https://projen.io/docs/concepts/projects/building-y 3. Initialize and define (at least) minimum required configuration: ```ts const project = new AlmaCdkConstructLibrary({ - name: "/", + name: "@/", // or "" author: "", authorAddress: "", description: "", diff --git a/src/AlmaCdkConstructLibrary.ts b/src/AlmaCdkConstructLibrary.ts index f72be13..78be306 100644 --- a/src/AlmaCdkConstructLibrary.ts +++ b/src/AlmaCdkConstructLibrary.ts @@ -6,7 +6,7 @@ import { almaCdkConstructLibraryOptionsSchema, type AlmaCdkConstructLibraryOptions, } from './schemas/almaCdkConstructLibraryOptions'; -import { parseScopedPackageName } from './schemas/name'; +import { parsePackageName } from './schemas/name'; import { SonarCloudReportWorkflow } from './SonarCloudReportWorkflow'; import { uniqueKeywordsCaseInsensitive } from './uniqueKeywordsCaseInsensitive'; @@ -44,12 +44,17 @@ function buildKeywords(keywords: readonly string[]): string[] { return uniqueKeywordsCaseInsensitive([...DEFAULT_KEYWORDS, ...keywords]); } +function toPythonModuleSegment(value: string): string { + return value.replace(/-/g, '_'); +} + function buildPublishToPypiOptions(name: string) { - const { scope, packageName } = parseScopedPackageName(name); + const { scope, packageName } = parsePackageName(name); + const packagePath = scope == null ? [packageName] : [scope, packageName]; return { - distName: `${scope}.${packageName}`, - module: `${scope.replace(/-/g, '_')}.${packageName.replace(/-/g, '_')}`, + distName: packagePath.join('.'), + module: packagePath.map(toPythonModuleSegment).join('.'), trustedPublishing: true, }; } @@ -123,6 +128,7 @@ export class AlmaCdkConstructLibrary extends awscdk.AwsCdkConstructLibrary { }); new SonarCloudReportWorkflow(this, { + repositoryUrl: validatedOptions.repositoryUrl, sonarProjectPropertiesExtraLines: validatedOptions.sonarProjectPropertiesExtraLines, }); diff --git a/src/SonarCloudReportWorkflow.ts b/src/SonarCloudReportWorkflow.ts index 8ed1c8f..9c1b9d2 100644 --- a/src/SonarCloudReportWorkflow.ts +++ b/src/SonarCloudReportWorkflow.ts @@ -1,23 +1,40 @@ import { awscdk, TextFile } from 'projen'; import { WorkflowSteps } from 'projen/lib/github'; import { JobPermission } from 'projen/lib/github/workflows-model'; -import { parseScopedPackageName } from './schemas/name'; +import { parsePackageName } from './schemas/name'; export interface SonarCloudReportWorkflowOptions { + readonly repositoryUrl: string; /** Lines appended after the default `sonar-project.properties` content. */ readonly sonarProjectPropertiesExtraLines?: readonly string[]; } +function parseGithubRepositoryUrl(repositoryUrl: string): { + owner: string; + repo: string; +} { + const url = new URL(repositoryUrl); + const [owner, repo] = url.pathname.replace(/^\//, '').replace(/\.git$/, '').split('/'); + + return { + owner: owner!, + repo: repo!, + }; +} + function buildSonarProjectPropertiesLines( projectName: string, + repositoryUrl: string, extraLines: readonly string[] = [], ): string[] { - const { scope, packageName } = parseScopedPackageName(projectName); + const { scope, packageName } = parsePackageName(projectName); + const { owner } = parseGithubRepositoryUrl(repositoryUrl); + const organization = scope ?? owner; return [ 'sonar.host.url=https://sonarcloud.io', - `sonar.projectKey=${scope}_${packageName}`, - `sonar.organization=${scope}`, + `sonar.projectKey=${organization}_${packageName}`, + `sonar.organization=${organization}`, 'sonar.javascript.lcov.reportPaths=./coverage/lcov.info', 'sonar.sources=./src', 'sonar.tests=./test', @@ -29,7 +46,7 @@ function buildSonarProjectPropertiesLines( export class SonarCloudReportWorkflow { constructor( project: awscdk.AwsCdkConstructLibrary, - options?: SonarCloudReportWorkflowOptions, + options: SonarCloudReportWorkflowOptions, ) { const sonarCloudReportWorkflow = project.github?.addWorkflow('sonarcloud-report'); @@ -73,7 +90,8 @@ export class SonarCloudReportWorkflow { */ const sonarProjectPropertiesLines = buildSonarProjectPropertiesLines( project.name, - options?.sonarProjectPropertiesExtraLines, + options.repositoryUrl, + options.sonarProjectPropertiesExtraLines, ); new TextFile(project, 'sonar-project.properties', { diff --git a/src/schemas/almaCdkConstructLibraryOptions.ts b/src/schemas/almaCdkConstructLibraryOptions.ts index ea8d22b..4c6117a 100644 --- a/src/schemas/almaCdkConstructLibraryOptions.ts +++ b/src/schemas/almaCdkConstructLibraryOptions.ts @@ -92,7 +92,7 @@ const NODEJS_MAX_VERSION = '24'; const NODEJS_WORKFLOW_VERSION = NODEJS_MAX_VERSION; -/** Projen AwsCdkConstructLibrary options with validation and defaults (min/max/workflow Node versions, scoped name, etc.). */ +/** Projen AwsCdkConstructLibrary options with validation and defaults (min/max/workflow Node versions, package name, etc.). */ // JSII cannot infer this schema shape cleanly, so we keep the runtime schema // and its public options interface aligned explicitly. export const almaCdkConstructLibraryOptionsSchema = z diff --git a/src/schemas/name.ts b/src/schemas/name.ts index 6935826..5fe5196 100644 --- a/src/schemas/name.ts +++ b/src/schemas/name.ts @@ -1,23 +1,55 @@ import { z } from 'zod'; -/** Scoped package name: must be @/ (single slash) */ +/** Package name: either `@scope/package-name` or `package-name`. */ export const nameSchema = z .string() .min(1) - .refine((name) => name.startsWith('@') && name.split('/').length === 2, { + .refine((name) => { + if (name.startsWith('@')) { + return name.split('/').length === 2; + } + + return !name.includes('/'); + }, { message: - 'Name must be a scoped package starting with "@" and contain exactly one "/"', + 'Name must be either an unscoped package name or a scoped package name like "@scope/package"', }); +export interface ParsedPackageName { + readonly packageName: string; + readonly scope?: string; +} + +/** + * Parse a package name into optional scope and package name. + * Input must already match nameSchema. + */ +export function parsePackageName(name: string): ParsedPackageName { + if (!name.startsWith('@')) { + return { packageName: name }; + } + + const withoutAt = name.slice(1); + const [scope, packageName] = withoutAt.split('/'); + return { scope: scope!, packageName: packageName! }; +} + /** * Parse a scoped package name into scope and package name. - * Input must already match nameSchema (e.g. @scope/package-name). + * Throws when given an unscoped package name. */ export function parseScopedPackageName(name: string): { - scope: string; - packageName: string; + readonly scope: string; + readonly packageName: string; } { - const withoutAt = name.replace('@', ''); - const [scope, packageName] = withoutAt.split('/'); - return { scope: scope!, packageName: packageName! }; + const parsedName = parsePackageName(name); + + if (parsedName.scope == null) { + throw new Error('Expected a scoped package name'); + } + + return { + scope: parsedName.scope, + packageName: parsedName.packageName, + }; } diff --git a/test/AlmaCdkConstructLibrary.test.ts b/test/AlmaCdkConstructLibrary.test.ts index 6cf8ac0..ec35164 100644 --- a/test/AlmaCdkConstructLibrary.test.ts +++ b/test/AlmaCdkConstructLibrary.test.ts @@ -35,7 +35,7 @@ test('constructor validates options before synthesis', () => { () => new AlmaCdkConstructLibrary({ ...baseLibraryOptions, - name: 'invalid-name', + name: 'invalid/name', }), ).toThrow(); }); @@ -93,6 +93,37 @@ test('package.json derives Python and Go publish metadata from package name', () ); }); +test('package.json derives Python publish metadata from unscoped package name', () => { + const snapshot = synthProject({ + name: 'my-hyphenated-package-name', + repositoryUrl: + 'https://github.com/alma-cdk/my-hyphenated-package-name.git', + }); + const packageJson = snapshot['package.json'] as { + jsii: { + targets: { + python: { + distName: string; + module: string; + }; + go: { + moduleName: string; + }; + }; + }; + }; + + expect(packageJson.jsii.targets.python.distName).toBe( + 'my-hyphenated-package-name', + ); + expect(packageJson.jsii.targets.python.module).toBe( + 'my_hyphenated_package_name', + ); + expect(packageJson.jsii.targets.go.moduleName).toBe( + 'github.com/alma-cdk/my-hyphenated-package-name-go', + ); +}); + test('package.json omits disabled Python and Go publish targets', () => { const snapshot = synthProject({ python: false, @@ -149,6 +180,17 @@ test('sonar-project.properties derives project coordinates from the scoped name' expect(sonarProjectProperties).toContain('sonar.organization=alma-cdk'); }); +test('sonar-project.properties derives organization from repository owner for unscoped names', () => { + const snapshot = synthProject({ + name: 'project', + repositoryUrl: 'https://github.com/alma-cdk/project.git', + }); + const sonarProjectProperties = snapshot['sonar-project.properties']; + + expect(sonarProjectProperties).toContain('sonar.projectKey=alma-cdk_project'); + expect(sonarProjectProperties).toContain('sonar.organization=alma-cdk'); +}); + test('sonar-project.properties appends sonarProjectPropertiesExtraLines', () => { const sonarProjectPropertiesExtraLines = [ 'sonar.issue.ignore.multicriteria=e1', diff --git a/test/schemas/almaCdkConstructLibraryOptions.test.ts b/test/schemas/almaCdkConstructLibraryOptions.test.ts index 1783ba3..cb1326b 100644 --- a/test/schemas/almaCdkConstructLibraryOptions.test.ts +++ b/test/schemas/almaCdkConstructLibraryOptions.test.ts @@ -133,11 +133,20 @@ describe('almaCdkConstructLibraryOptionsSchema', () => { ).toThrow('Node versions must satisfy min <= workflow <= max'); }); + it('accepts valid unscoped name', () => { + const result = almaCdkConstructLibraryOptionsSchema.parse({ + ...validBaseOptions, + name: 'project', + }); + + expect(result.name).toBe('project'); + }); + it('rejects invalid name', () => { expect(() => almaCdkConstructLibraryOptionsSchema.parse({ ...validBaseOptions, - name: 'invalid-name', + name: 'invalid/name', }), ).toThrow(); }); diff --git a/test/schemas/name.test.ts b/test/schemas/name.test.ts index 8cddc61..0521c50 100644 --- a/test/schemas/name.test.ts +++ b/test/schemas/name.test.ts @@ -1,5 +1,6 @@ import { nameSchema, + parsePackageName, parseScopedPackageName, } from '../../src/schemas/name'; @@ -10,11 +11,16 @@ describe('nameSchema', () => { expect(nameSchema.parse('@foo/bar-baz')).toBe('@foo/bar-baz'); }); + it('accepts valid unscoped package names', () => { + expect(nameSchema.parse('package')).toBe('package'); + expect(nameSchema.parse('my-package')).toBe('my-package'); + }); + it('rejects empty string', () => { expect(() => nameSchema.parse('')).toThrow(); }); - it('rejects names without @', () => { + it('rejects slash-separated names without @', () => { expect(() => nameSchema.parse('scope/package')).toThrow(); }); @@ -23,11 +29,30 @@ describe('nameSchema', () => { expect(() => nameSchema.parse('@scope/pkg/sub')).toThrow(); }); - it('rejects names with no slash', () => { + it('rejects scoped names with no slash', () => { expect(() => nameSchema.parse('@scopepackage')).toThrow(); }); }); +describe('parsePackageName', () => { + it('parses unscoped package names', () => { + expect(parsePackageName('package')).toEqual({ + packageName: 'package', + }); + }); + + it('parses scoped package names', () => { + expect(parsePackageName('@scope/package')).toEqual({ + scope: 'scope', + packageName: 'package', + }); + expect(parsePackageName('@alma-cdk/project')).toEqual({ + scope: 'alma-cdk', + packageName: 'project', + }); + }); +}); + describe('parseScopedPackageName', () => { it('parses scope and package name', () => { expect(parseScopedPackageName('@scope/package')).toEqual({ @@ -39,4 +64,10 @@ describe('parseScopedPackageName', () => { packageName: 'project', }); }); + + it('rejects unscoped package names', () => { + expect(() => parseScopedPackageName('project')).toThrow( + 'Expected a scoped package name', + ); + }); });