Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<SCOPE>/<PACKAGE_NAME>",
name: "@<SCOPE>/<PACKAGE_NAME>", // or "<PACKAGE_NAME>"
author: "<AUTHOR_ORGANIZATION_NAME>",
authorAddress: "<AUTHOR_ORGANIZATION_EMAIL>",
description: "<PACKAGE_DESCRIPTION>",
Expand Down
14 changes: 10 additions & 4 deletions src/AlmaCdkConstructLibrary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
};
}
Expand Down Expand Up @@ -123,6 +128,7 @@ export class AlmaCdkConstructLibrary extends awscdk.AwsCdkConstructLibrary {
});

new SonarCloudReportWorkflow(this, {
repositoryUrl: validatedOptions.repositoryUrl,
sonarProjectPropertiesExtraLines:
validatedOptions.sonarProjectPropertiesExtraLines,
});
Expand Down
30 changes: 24 additions & 6 deletions src/SonarCloudReportWorkflow.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -29,7 +46,7 @@ function buildSonarProjectPropertiesLines(
export class SonarCloudReportWorkflow {
constructor(
project: awscdk.AwsCdkConstructLibrary,
options?: SonarCloudReportWorkflowOptions,
options: SonarCloudReportWorkflowOptions,
) {
const sonarCloudReportWorkflow =
project.github?.addWorkflow('sonarcloud-report');
Expand Down Expand Up @@ -73,7 +90,8 @@ export class SonarCloudReportWorkflow {
*/
const sonarProjectPropertiesLines = buildSonarProjectPropertiesLines(
project.name,
options?.sonarProjectPropertiesExtraLines,
options.repositoryUrl,
options.sonarProjectPropertiesExtraLines,
);

new TextFile(project, 'sonar-project.properties', {
Expand Down
2 changes: 1 addition & 1 deletion src/schemas/almaCdkConstructLibraryOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 41 additions & 9 deletions src/schemas/name.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,55 @@
import { z } from 'zod';

/** Scoped package name: must be @<scope>/<package-name> (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,
};
}
44 changes: 43 additions & 1 deletion test/AlmaCdkConstructLibrary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ test('constructor validates options before synthesis', () => {
() =>
new AlmaCdkConstructLibrary({
...baseLibraryOptions,
name: 'invalid-name',
name: 'invalid/name',
}),
).toThrow();
});
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
11 changes: 10 additions & 1 deletion test/schemas/almaCdkConstructLibraryOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
35 changes: 33 additions & 2 deletions test/schemas/name.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
nameSchema,
parsePackageName,
parseScopedPackageName,
} from '../../src/schemas/name';

Expand All @@ -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();
});

Expand All @@ -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({
Expand All @@ -39,4 +64,10 @@ describe('parseScopedPackageName', () => {
packageName: 'project',
});
});

it('rejects unscoped package names', () => {
expect(() => parseScopedPackageName('project')).toThrow(
'Expected a scoped package name',
);
});
});
Loading