Skip to content

Commit 32f3e51

Browse files
authored
feat(plugin-typescript): add setup wizard binding
1 parent 54501b3 commit 32f3e51

File tree

8 files changed

+257
-3
lines changed

8 files changed

+257
-3
lines changed

packages/create-cli/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ Each plugin exposes its own configuration keys that can be passed as CLI argumen
5858
| **`--js-packages.dependencyGroups`** | `('prod'` \| `'dev'` \| `'optional')[]` | `prod`, `dev` | Dependency groups |
5959
| **`--js-packages.categories`** | `boolean` | `true` | Add JS packages categories |
6060

61+
#### TypeScript
62+
63+
| Option | Type | Default | Description |
64+
| ----------------------------- | --------- | ------------- | ------------------------- |
65+
| **`--typescript.tsconfig`** | `string` | auto-detected | TypeScript config file |
66+
| **`--typescript.categories`** | `boolean` | `true` | Add TypeScript categories |
67+
6168
### Examples
6269

6370
Run interactively (default):

packages/create-cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"@code-pushup/eslint-plugin": "0.122.1",
3131
"@code-pushup/js-packages-plugin": "0.122.1",
3232
"@code-pushup/models": "0.122.1",
33+
"@code-pushup/typescript-plugin": "0.122.1",
3334
"@code-pushup/utils": "0.122.1",
3435
"@inquirer/prompts": "^8.0.0",
3536
"yaml": "^2.5.1",

packages/create-cli/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { hideBin } from 'yargs/helpers';
44
import { coverageSetupBinding } from '@code-pushup/coverage-plugin';
55
import { eslintSetupBinding } from '@code-pushup/eslint-plugin';
66
import { jsPackagesSetupBinding } from '@code-pushup/js-packages-plugin';
7+
import { typescriptSetupBinding } from '@code-pushup/typescript-plugin';
78
import { parsePluginSlugs, validatePluginSlugs } from './lib/setup/plugins.js';
89
import {
910
CI_PROVIDERS,
@@ -13,11 +14,12 @@ import {
1314
} from './lib/setup/types.js';
1415
import { runSetupWizard } from './lib/setup/wizard.js';
1516

16-
// TODO: create, import and pass remaining plugin bindings (lighthouse, typescript, jsdocs, axe)
17+
// TODO: create, import and pass remaining plugin bindings (lighthouse, jsdocs, axe)
1718
const bindings: PluginSetupBinding[] = [
1819
eslintSetupBinding,
1920
coverageSetupBinding,
2021
jsPackagesSetupBinding,
22+
typescriptSetupBinding,
2123
];
2224

2325
const argv = await yargs(hideBin(process.argv))

packages/plugin-typescript/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { typescriptPlugin } from './lib/typescript-plugin.js';
22

33
export default typescriptPlugin;
44

5+
export { typescriptSetupBinding } from './lib/binding.js';
56
export { TYPESCRIPT_PLUGIN_SLUG } from './lib/constants.js';
67
export {
78
typescriptPluginConfigSchema,
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { readdir } from 'node:fs/promises';
2+
import { createRequire } from 'node:module';
3+
import path from 'node:path';
4+
import type {
5+
CategoryConfig,
6+
PluginAnswer,
7+
PluginSetupBinding,
8+
} from '@code-pushup/models';
9+
import {
10+
answerBoolean,
11+
answerString,
12+
fileExists,
13+
singleQuote,
14+
} from '@code-pushup/utils';
15+
import {
16+
DEFAULT_TS_CONFIG,
17+
TSCONFIG_PATTERN,
18+
TYPESCRIPT_PLUGIN_SLUG,
19+
TYPESCRIPT_PLUGIN_TITLE,
20+
} from './constants.js';
21+
22+
const { name: PACKAGE_NAME } = createRequire(import.meta.url)(
23+
'../../package.json',
24+
) as typeof import('../../package.json');
25+
26+
const TYPESCRIPT_CATEGORIES: CategoryConfig[] = [
27+
{
28+
slug: 'bug-prevention',
29+
title: 'Bug prevention',
30+
description: 'Type checks that find **potential bugs** in your code.',
31+
refs: [
32+
{
33+
type: 'group',
34+
plugin: TYPESCRIPT_PLUGIN_SLUG,
35+
slug: 'problems',
36+
weight: 1,
37+
},
38+
],
39+
},
40+
];
41+
42+
type TypescriptOptions = {
43+
tsconfig: string;
44+
categories: boolean;
45+
};
46+
47+
export const typescriptSetupBinding = {
48+
slug: TYPESCRIPT_PLUGIN_SLUG,
49+
title: TYPESCRIPT_PLUGIN_TITLE,
50+
packageName: PACKAGE_NAME,
51+
isRecommended,
52+
prompts: async (targetDir: string) => {
53+
const tsconfig = await detectTsconfig(targetDir);
54+
return [
55+
{
56+
key: 'typescript.tsconfig',
57+
message: 'TypeScript config file',
58+
type: 'input',
59+
default: tsconfig,
60+
},
61+
{
62+
key: 'typescript.categories',
63+
message: 'Add TypeScript categories?',
64+
type: 'confirm',
65+
default: true,
66+
},
67+
];
68+
},
69+
generateConfig: (answers: Record<string, PluginAnswer>) => {
70+
const options = parseAnswers(answers);
71+
return {
72+
imports: [
73+
{ moduleSpecifier: PACKAGE_NAME, defaultImport: 'typescriptPlugin' },
74+
],
75+
pluginInit: formatPluginInit(options),
76+
...(options.categories ? { categories: TYPESCRIPT_CATEGORIES } : {}),
77+
};
78+
},
79+
} satisfies PluginSetupBinding;
80+
81+
function parseAnswers(
82+
answers: Record<string, PluginAnswer>,
83+
): TypescriptOptions {
84+
return {
85+
tsconfig: answerString(answers, 'typescript.tsconfig') || DEFAULT_TS_CONFIG,
86+
categories: answerBoolean(answers, 'typescript.categories'),
87+
};
88+
}
89+
90+
function formatPluginInit({ tsconfig }: TypescriptOptions): string[] {
91+
return tsconfig === DEFAULT_TS_CONFIG
92+
? ['typescriptPlugin(),']
93+
: ['typescriptPlugin({', ` tsconfig: ${singleQuote(tsconfig)},`, '}),'];
94+
}
95+
96+
async function isRecommended(targetDir: string): Promise<boolean> {
97+
return (
98+
(await fileExists(path.join(targetDir, 'tsconfig.json'))) ||
99+
(await fileExists(path.join(targetDir, 'tsconfig.base.json')))
100+
);
101+
}
102+
103+
async function detectTsconfig(targetDir: string): Promise<string> {
104+
const files = await readdir(targetDir, { encoding: 'utf8' });
105+
const match = files.find(file => TSCONFIG_PATTERN.test(file));
106+
return match ?? DEFAULT_TS_CONFIG;
107+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { vol } from 'memfs';
2+
import type { PluginAnswer } from '@code-pushup/models';
3+
import { MEMFS_VOLUME } from '@code-pushup/test-utils';
4+
import { typescriptSetupBinding as binding } from './binding.js';
5+
6+
const defaultAnswers: Record<string, PluginAnswer> = {
7+
'typescript.tsconfig': 'tsconfig.json',
8+
'typescript.categories': true,
9+
};
10+
11+
describe('typescriptSetupBinding', () => {
12+
beforeEach(() => {
13+
vol.fromJSON({ '.gitkeep': '' }, MEMFS_VOLUME);
14+
});
15+
16+
describe('isRecommended', () => {
17+
it('should recommend when tsconfig.json exists', async () => {
18+
vol.fromJSON({ 'tsconfig.json': '{}' }, MEMFS_VOLUME);
19+
20+
await expect(binding.isRecommended(MEMFS_VOLUME)).resolves.toBeTrue();
21+
});
22+
23+
it('should recommend when tsconfig.base.json exists', async () => {
24+
vol.fromJSON({ 'tsconfig.base.json': '{}' }, MEMFS_VOLUME);
25+
26+
await expect(binding.isRecommended(MEMFS_VOLUME)).resolves.toBeTrue();
27+
});
28+
29+
it('should not recommend when no tsconfig found', async () => {
30+
await expect(binding.isRecommended(MEMFS_VOLUME)).resolves.toBeFalse();
31+
});
32+
});
33+
34+
describe('prompts', () => {
35+
it('should detect tsconfig.json as default', async () => {
36+
vol.fromJSON({ 'tsconfig.json': '{}' }, MEMFS_VOLUME);
37+
38+
await expect(
39+
binding.prompts(MEMFS_VOLUME),
40+
).resolves.toIncludeAllPartialMembers([
41+
{ key: 'typescript.tsconfig', default: 'tsconfig.json' },
42+
]);
43+
});
44+
45+
it('should detect tsconfig.base.json when present', async () => {
46+
vol.fromJSON({ 'tsconfig.base.json': '{}' }, MEMFS_VOLUME);
47+
48+
await expect(
49+
binding.prompts(MEMFS_VOLUME),
50+
).resolves.toIncludeAllPartialMembers([
51+
{ key: 'typescript.tsconfig', default: 'tsconfig.base.json' },
52+
]);
53+
});
54+
55+
it('should detect tsconfig.app.json when present', async () => {
56+
vol.fromJSON({ 'tsconfig.app.json': '{}' }, MEMFS_VOLUME);
57+
58+
await expect(
59+
binding.prompts(MEMFS_VOLUME),
60+
).resolves.toIncludeAllPartialMembers([
61+
{ key: 'typescript.tsconfig', default: 'tsconfig.app.json' },
62+
]);
63+
});
64+
65+
it('should default to tsconfig.json when no tsconfig found', async () => {
66+
await expect(
67+
binding.prompts(MEMFS_VOLUME),
68+
).resolves.toIncludeAllPartialMembers([
69+
{ key: 'typescript.tsconfig', default: 'tsconfig.json' },
70+
]);
71+
});
72+
73+
it('should default categories confirmation to true', async () => {
74+
await expect(
75+
binding.prompts(MEMFS_VOLUME),
76+
).resolves.toIncludeAllPartialMembers([
77+
{ key: 'typescript.categories', type: 'confirm', default: true },
78+
]);
79+
});
80+
});
81+
82+
describe('generateConfig', () => {
83+
it('should omit tsconfig option when using default tsconfig.json', () => {
84+
expect(binding.generateConfig(defaultAnswers).pluginInit).toEqual([
85+
'typescriptPlugin(),',
86+
]);
87+
});
88+
89+
it('should include tsconfig when non-default path provided', () => {
90+
expect(
91+
binding.generateConfig({
92+
...defaultAnswers,
93+
'typescript.tsconfig': 'tsconfig.base.json',
94+
}).pluginInit,
95+
).toEqual([
96+
'typescriptPlugin({',
97+
" tsconfig: 'tsconfig.base.json',",
98+
'}),',
99+
]);
100+
});
101+
102+
it('should generate bug-prevention category from problems group when confirmed', () => {
103+
expect(binding.generateConfig(defaultAnswers).categories).toEqual([
104+
expect.objectContaining({
105+
slug: 'bug-prevention',
106+
refs: [
107+
expect.objectContaining({
108+
type: 'group',
109+
plugin: 'typescript',
110+
slug: 'problems',
111+
}),
112+
],
113+
}),
114+
]);
115+
});
116+
117+
it('should omit categories when declined', () => {
118+
expect(
119+
binding.generateConfig({
120+
...defaultAnswers,
121+
'typescript.categories': false,
122+
}).categories,
123+
).toBeUndefined();
124+
});
125+
126+
it('should import from @code-pushup/typescript-plugin', () => {
127+
expect(binding.generateConfig(defaultAnswers).imports).toEqual([
128+
{
129+
moduleSpecifier: '@code-pushup/typescript-plugin',
130+
defaultImport: 'typescriptPlugin',
131+
},
132+
]);
133+
});
134+
});
135+
});

packages/plugin-typescript/src/lib/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export const TYPESCRIPT_PLUGIN_TITLE = 'TypeScript';
88

99
export const DEFAULT_TS_CONFIG = 'tsconfig.json';
1010

11+
export const TSCONFIG_PATTERN = /^tsconfig(\..+)?\.json$/;
12+
1113
const AUDIT_DESCRIPTIONS: Record<AuditSlug, string> = {
1214
'semantic-errors':
1315
'Errors that occur during type checking and type inference',

packages/plugin-typescript/src/lib/nx/tsconfig-paths.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@ import { readdir } from 'node:fs/promises';
33
import path from 'node:path';
44
import { readConfigFile, sys } from 'typescript';
55
import { loadNxProjectGraph, logger, pluralizeToken } from '@code-pushup/utils';
6+
import { TSCONFIG_PATTERN } from '../constants.js';
67
import { formatMetaLog } from '../format.js';
78

8-
const TSCONFIG_PATTERN = /^tsconfig(\..+)?\.json$/;
9-
109
/**
1110
* Returns true only if config explicitly defines files or include with values.
1211
*/

0 commit comments

Comments
 (0)