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
27 changes: 25 additions & 2 deletions packages/@aws-cdk/toolkit-lib/lib/api/notices/filter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as semver from 'semver';
import { languageDisplayName } from '../../util/guess-language';
import type { IoHelper } from '../io/private';
import type { ConstructTreeNode } from '../tree';
import { loadTreeFromDir } from '../tree';
Expand All @@ -11,8 +12,15 @@ function normalizeComponents(xs: Array<Component | Component[]>): Component[][]
return xs.map(x => Array.isArray(x) ? x : [x]);
}

function renderComponent(c: Component): string {
if (c.name.startsWith('language:')) {
return `${languageDisplayName(c.name.slice('language:'.length))} apps`;
}
return `${c.name}: ${c.version}`;
}

function renderConjunction(xs: Component[]): string {
return xs.map(c => `${c.name}: ${c.version}`).join(' AND ');
return xs.map(renderComponent).join(' AND ');
}

interface ActualComponent {
Expand Down Expand Up @@ -40,7 +48,7 @@ interface ActualComponent {
readonly dynamicName?: string;

/**
* If matched, what we should put in the set of dynamic values insstead of the version.
* If matched, what we should put in the set of dynamic values instead of the version.
*
* Only used if `dynamicName` is set; by default we will add the actual version
* of the component.
Expand All @@ -55,6 +63,13 @@ export interface NoticesFilterFilterOptions {
readonly cliVersion: string;
readonly outDir: string;
readonly bootstrappedEnvironments: BootstrappedEnvironment[];

/**
* The detected CDK app language.
*
* @default - no language component is added
*/
readonly language?: string;
}

export class NoticesFilter {
Expand Down Expand Up @@ -111,6 +126,14 @@ export class NoticesFilter {

// Bootstrap environments
...bootstrappedEnvironments,

// Language
...(options.language ? [{
name: `language:${options.language}`,
version: '0.0.0',
dynamicName: 'LANGUAGE',
dynamicValue: languageDisplayName(options.language),
}] : []),
];
}

Expand Down
12 changes: 11 additions & 1 deletion packages/@aws-cdk/toolkit-lib/lib/api/notices/notices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ export interface NoticesProps {
*/
readonly output?: string;

/**
* The detected CDK app language.
*
* @default - no language filtering
*/
readonly language?: string;

/**
* Options for the HTTPS requests made by Notices
*/
Expand Down Expand Up @@ -113,6 +120,7 @@ export class Notices {

private readonly context: Context;
private readonly output: string;
private readonly language?: string;
private readonly acknowledgedIssueNumbers: Set<Number>;
private readonly httpOptions: NoticesHttpOptions;
private readonly ioHelper: IoHelper;
Expand All @@ -127,6 +135,7 @@ export class Notices {
this.context = props.context;
this.acknowledgedIssueNumbers = new Set(this.context.get('acknowledged-issue-numbers') ?? []);
this.output = props.output ?? 'cdk.out';
this.language = props.language;
this.httpOptions = props.httpOptions ?? {};
this.ioHelper = asIoHelper(props.ioHost, 'notices' as any /* forcing a CliAction to a ToolkitAction */);
this.cliVersion = props.cliVersion;
Expand Down Expand Up @@ -164,12 +173,13 @@ export class Notices {
/**
* Filter the data source for relevant notices
*/
public filter(options: NoticesDisplayOptions = {}): Promise<FilteredNotice[]> {
public async filter(options: NoticesDisplayOptions = {}): Promise<FilteredNotice[]> {
return new NoticesFilter(this.ioHelper).filter({
data: this.noticesFromData(options.includeAcknowledged ?? false),
cliVersion: this.cliVersion,
outDir: this.output,
bootstrappedEnvironments: Array.from(this.bootstrappedEnvironments.values()),
language: this.language,
});
}

Expand Down
25 changes: 25 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/util/directories.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as fsExtra from 'fs-extra';
import { ToolkitError } from '../toolkit/toolkit-error';

/**
Expand Down Expand Up @@ -63,3 +64,27 @@ export function bundledPackageRootDir(start: string, fail?: boolean) {

return _rootDir(start);
}

/**
* Recursively lists all files in a directory up to the specified depth.
*
* @param dirName - The directory path to list files from
* @param depth - Maximum depth to traverse (1 = current directory only, 2 = one level deep, etc.)
* @returns Array of file names (not full paths) found within the depth limit
*/
export async function listFiles(dirName: string, depth: number, excludeDirs?: string[]): Promise<string[]> {
const ret = await fsExtra.readdir(dirName, { encoding: 'utf-8', withFileTypes: true });

// unlikely to be unbound, it's a file system
// eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism
return (await Promise.all(ret.map(async (f) => {
if (f.isDirectory()) {
if (depth <= 1 || excludeDirs?.includes(f.name)) {
return [];
}
return listFiles(path.join(dirName, f.name), depth - 1, excludeDirs);
} else {
return [f.name];
}
}))).flatMap(xs => xs);
}
63 changes: 63 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/util/guess-language.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as path from 'path';
import * as fs from 'fs-extra';
import { listFiles } from './directories';

const DISPLAY_NAMES: Record<string, string> = {
typescript: 'TypeScript',
javascript: 'JavaScript',
python: 'Python',
java: 'Java',
dotnet: '.NET',
go: 'Go',
};

/**
* Return the display name for a language identifier.
*/
export function languageDisplayName(language: string): string {
return DISPLAY_NAMES[language] ?? language;
}

/**
* Guess the CDK app language based on the files in the given directory.
*
* Returns `undefined` if our guess fails.
*/
export async function guessLanguage(dir: string): Promise<string | undefined> {
try {
const files = new Set(await listFiles(dir, 2, ['node_modules']));

if (files.has('package.json')) {
const pjContents = JSON.parse(await fs.readFile(path.join(dir, 'package.json'), 'utf-8'));
const deps = new Set([
...Object.keys(pjContents.dependencies ?? {}),
...Object.keys(pjContents.devDependencies ?? {}),
]);
if (deps.has('typescript') || deps.has('ts-node') || deps.has('tsx') || deps.has('swc')) {
return 'typescript';
} else {
return 'javascript';
}
}

if (files.has('requirements.txt') || files.has('setup.py') || files.has('pyproject.toml')) {
return 'python';
}

if (files.has('pom.xml') || files.has('build.xml') || files.has('settings.gradle')) {
return 'java';
}

if (Array.from(files).some(n => n.endsWith('.sln') || n.endsWith('.csproj') || n.endsWith('.fsproj') || n.endsWith('.vbproj'))) {
return 'dotnet';
}

if (files.has('go.mod')) {
return 'go';
}
} catch {
// Swallow failure
}

return undefined;
}
3 changes: 2 additions & 1 deletion packages/@aws-cdk/toolkit-lib/lib/util/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
export * from './archive';
export * from './arrays';
export * from './glob-matcher';
export * from './bool';
export * from './bytes';
export * from './cloudformation';
export * from './content-hash';
export * from './directories';
export * from './format-error';
export * from './glob-matcher';
export * from './guess-language';
export * from './json';
export * from './net';
export * from './objects';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ describe('validate version without bootstrap stack', () => {
cliVersion: '1.0.0',
data: [],
outDir: 'cdk.out',
language: undefined,
});
});

Expand Down
70 changes: 70 additions & 0 deletions packages/@aws-cdk/toolkit-lib/test/api/guess-language.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import * as path from 'path';
import * as fs from 'fs-extra';
import { guessLanguage } from '../../lib/util/guess-language';

describe('guessLanguage', () => {
let tmpDir: string;

beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(__dirname, 'guess-lang-'));
});

afterEach(async () => {
await fs.remove(tmpDir);
});

test('returns typescript when package.json has typescript dependency', async () => {
await fs.writeJson(path.join(tmpDir, 'package.json'), {
dependencies: { typescript: '^5.0.0' },
});
expect(await guessLanguage(tmpDir)).toBe('typescript');
});

test('returns typescript when package.json has ts-node devDependency', async () => {
await fs.writeJson(path.join(tmpDir, 'package.json'), {
devDependencies: { 'ts-node': '^10.0.0' },
});
expect(await guessLanguage(tmpDir)).toBe('typescript');
});

test('returns javascript when package.json has no typescript indicators', async () => {
await fs.writeJson(path.join(tmpDir, 'package.json'), {
dependencies: { 'aws-cdk-lib': '^2.0.0' },
});
expect(await guessLanguage(tmpDir)).toBe('javascript');
});

test('returns python for requirements.txt', async () => {
await fs.writeFile(path.join(tmpDir, 'requirements.txt'), '');
expect(await guessLanguage(tmpDir)).toBe('python');
});

test('returns python for pyproject.toml', async () => {
await fs.writeFile(path.join(tmpDir, 'pyproject.toml'), '');
expect(await guessLanguage(tmpDir)).toBe('python');
});

test('returns java for pom.xml', async () => {
await fs.writeFile(path.join(tmpDir, 'pom.xml'), '');
expect(await guessLanguage(tmpDir)).toBe('java');
});

test('returns dotnet for .csproj file', async () => {
await fs.writeFile(path.join(tmpDir, 'MyApp.csproj'), '');
expect(await guessLanguage(tmpDir)).toBe('dotnet');
});

test('returns go for go.mod', async () => {
await fs.writeFile(path.join(tmpDir, 'go.mod'), '');
expect(await guessLanguage(tmpDir)).toBe('go');
});

test('returns undefined for unknown project', async () => {
await fs.writeFile(path.join(tmpDir, 'README.md'), '');
expect(await guessLanguage(tmpDir)).toBeUndefined();
});

test('returns undefined for non-existent directory', async () => {
expect(await guessLanguage('/nonexistent/path')).toBeUndefined();
});
});
81 changes: 81 additions & 0 deletions packages/@aws-cdk/toolkit-lib/test/api/notices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,86 @@ describe(NoticesFilter, () => {
expect((await filtered).map((f) => f.format()).join('\n')).toContain(`You are running ${nodeVersion}`);
});

test('language match', async () => {
const outDir = path.join(fixtures, 'built-with-2_12_0');
const cliVersion = '1.0.0';

const filtered = await noticesFilter.filter({
data: [
{
title: 'title for typescript',
overview: 'This affects {resolve:LANGUAGE} users',
issueNumber: 1234,
schemaVersion: '1',
components: [{ name: 'language:typescript', version: '*' }],
},
{
title: 'title for python',
overview: 'python issue',
issueNumber: 4321,
schemaVersion: '1',
components: [{ name: 'language:python', version: '*' }],
},
] satisfies Notice[],
cliVersion,
outDir,
bootstrappedEnvironments: [],
language: 'typescript',
});

expect(filtered.map((f) => f.notice.title)).toEqual(['title for typescript']);
expect(filtered.map((f) => f.format()).join('\n')).toContain('This affects TypeScript users');
expect(filtered.map((f) => f.format()).join('\n')).toContain('TypeScript apps');
});

test('no language match when language is not provided', async () => {
const outDir = path.join(fixtures, 'built-with-2_12_0');
const cliVersion = '1.0.0';

const filtered = noticesFilter.filter({
data: [
{
title: 'typescript-only',
overview: 'ts issue',
issueNumber: 1,
schemaVersion: '1',
components: [{ name: 'language:typescript', version: '*' }],
},
] satisfies Notice[],
cliVersion,
outDir,
bootstrappedEnvironments: [],
});

expect((await filtered).map((f) => f.notice.title)).toEqual([]);
});

test('language combined with cli version in AND', async () => {
const outDir = path.join(fixtures, 'built-with-2_12_0');
const cliVersion = '1.0.0';

const filtered = noticesFilter.filter({
data: [
{
title: 'combined',
overview: 'combined issue',
issueNumber: 1,
schemaVersion: '1',
components: [[
{ name: 'language:typescript', version: '*' },
{ name: 'cli', version: '<=1.0.0' },
]],
},
] satisfies Notice[],
cliVersion,
outDir,
bootstrappedEnvironments: [],
language: 'typescript',
});

expect((await filtered).map((f) => f.notice.title)).toEqual(['combined']);
});

test.each([
// No components => doesnt match
[[], false],
Expand Down Expand Up @@ -901,6 +981,7 @@ describe(Notices, () => {
cliVersion: '1.0.0',
data: [],
outDir: 'cdk.out',
language: undefined,
});
});
});
Expand Down
Loading
Loading