@ankhorage/devtools 1.2.1 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +146 -99
- package/dist/cli/commands.d.ts +2 -2
- package/dist/cli/commands.js +31 -75
- package/dist/cli/index.d.ts +2 -2
- package/dist/cli/index.js +19 -0
- package/dist/cli/runRepositoryCommand.js +40 -27
- package/dist/internal/readmeDocs.js +10 -0
- package/dist/tools/eslint/index.d.ts +1 -1
- package/dist/tools/eslint/index.js +151 -66
- package/dist/tools/eslint/managed.d.ts +10 -0
- package/dist/tools/eslint/managed.js +44 -0
- package/dist/tools/eslint/profile.d.ts +4 -0
- package/dist/tools/eslint/profile.js +81 -0
- package/dist/tools/eslint/types.d.ts +5 -1
- package/dist/tools/knip/index.d.ts +13 -0
- package/dist/tools/knip/managed.d.ts +5 -0
- package/dist/tools/knip/managed.js +11 -0
- package/dist/tools/package/index.d.ts +8 -0
- package/dist/tools/package/index.js +172 -0
- package/dist/tools/prettier/managed.d.ts +6 -0
- package/dist/tools/prettier/managed.js +34 -0
- package/dist/tools/shared/managedFiles.d.ts +6 -1
- package/dist/tools/shared/managedFiles.js +44 -33
- package/package.json +17 -2
|
@@ -1,10 +1,36 @@
|
|
|
1
|
+
/***
|
|
2
|
+
* Create the shared strict ESLint flat configuration used by consuming repositories.
|
|
3
|
+
*
|
|
4
|
+
* The default `profile: 'auto'` reads the nearest `package.json` from `tsconfigRootDir` and uses
|
|
5
|
+
* `@ankhorage/utility/project` to select overlapping project traits. React Native and Expo select
|
|
6
|
+
* the `react-native` profile, React and Next.js select `react`, and other projects use `base`.
|
|
7
|
+
* Consumers can explicitly select `base`, `react`, or `react-native` when automatic detection is
|
|
8
|
+
* not appropriate.
|
|
9
|
+
*
|
|
10
|
+
* Every profile includes the shared TypeScript, import, unused-import, Prettier, security, and
|
|
11
|
+
* quality rules. The common quality limits are 50 effective lines per function, 300 effective
|
|
12
|
+
* lines per file, and modified cyclomatic complexity 15. React adds React and Hooks correctness
|
|
13
|
+
* rules; React Native composes the React profile and adds focused React Native rules.
|
|
14
|
+
*
|
|
15
|
+
* Repository-specific behavior stays additive: `additionalIgnores`, `restrictedImports`, and
|
|
16
|
+
* `overrides` extend the central policy instead of replacing it. Narrow local overrides remain the
|
|
17
|
+
* supported migration mechanism for legacy violations while organization-wide defaults stay
|
|
18
|
+
* strict.
|
|
19
|
+
*
|
|
20
|
+
* @readme
|
|
21
|
+
*/
|
|
1
22
|
import js from '@eslint/js';
|
|
2
23
|
import prettierConfig from 'eslint-config-prettier';
|
|
3
24
|
import importPlugin from 'eslint-plugin-import';
|
|
4
25
|
import prettierPlugin from 'eslint-plugin-prettier';
|
|
26
|
+
import reactPlugin from 'eslint-plugin-react';
|
|
27
|
+
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
|
28
|
+
import reactNativePlugin from 'eslint-plugin-react-native';
|
|
29
|
+
import securityPlugin from 'eslint-plugin-security';
|
|
5
30
|
import simpleImportSort from 'eslint-plugin-simple-import-sort';
|
|
6
31
|
import unusedImports from 'eslint-plugin-unused-imports';
|
|
7
32
|
import tseslint from 'typescript-eslint';
|
|
33
|
+
import { resolveEslintProfile } from './profile.js';
|
|
8
34
|
export const defaultIgnores = [
|
|
9
35
|
'**/ios/**',
|
|
10
36
|
'**/android/**',
|
|
@@ -24,79 +50,138 @@ export const defaultRestrictedImports = [
|
|
|
24
50
|
},
|
|
25
51
|
];
|
|
26
52
|
export function createConfig(options) {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
53
|
+
const normalized = normalizeOptions(options);
|
|
54
|
+
const profile = resolveEslintProfile(options);
|
|
55
|
+
return tseslint.config({ ignores: [...defaultIgnores, ...normalized.additionalIgnores] }, js.configs.recommended, ...createTypeCheckedConfigs(normalized), createBaseConfig(normalized), ...createProfileConfigs(profile, normalized.files), ...normalized.overrides, ...(normalized.includePrettier ? [prettierConfig] : []));
|
|
56
|
+
}
|
|
57
|
+
function normalizeOptions(options) {
|
|
58
|
+
return {
|
|
59
|
+
tsconfigRootDir: options.tsconfigRootDir,
|
|
60
|
+
project: options.project,
|
|
61
|
+
files: options.files,
|
|
62
|
+
allowDefaultProject: options.allowDefaultProject ?? [],
|
|
63
|
+
additionalIgnores: options.additionalIgnores ?? [],
|
|
64
|
+
restrictedImports: [...(options.restrictedImports ?? [])],
|
|
65
|
+
overrides: [...(options.overrides ?? [])],
|
|
66
|
+
includePrettier: options.includePrettier ?? true,
|
|
34
67
|
};
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
68
|
+
}
|
|
69
|
+
function createTypeCheckedConfigs(options) {
|
|
70
|
+
const configs = [
|
|
71
|
+
...tseslint.configs.recommendedTypeChecked,
|
|
72
|
+
...tseslint.configs.stylisticTypeChecked,
|
|
38
73
|
];
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
};
|
|
45
|
-
return tseslint.config({
|
|
46
|
-
ignores: [...defaultIgnores, ...normalizedOptions.additionalIgnores],
|
|
47
|
-
}, js.configs.recommended, ...tseslint.configs.recommendedTypeChecked.map((config) => ({
|
|
48
|
-
...config,
|
|
49
|
-
files: normalizedOptions.files,
|
|
50
|
-
})), ...tseslint.configs.stylisticTypeChecked.map((config) => ({
|
|
51
|
-
...config,
|
|
52
|
-
files: normalizedOptions.files,
|
|
53
|
-
})), {
|
|
54
|
-
files: normalizedOptions.files,
|
|
74
|
+
return configs.map((config) => ({ ...config, files: options.files }));
|
|
75
|
+
}
|
|
76
|
+
function createBaseConfig(options) {
|
|
77
|
+
return {
|
|
78
|
+
files: options.files,
|
|
55
79
|
languageOptions: {
|
|
56
80
|
parser: tseslint.parser,
|
|
57
81
|
parserOptions: {
|
|
58
|
-
project:
|
|
59
|
-
tsconfigRootDir:
|
|
60
|
-
allowDefaultProject:
|
|
82
|
+
project: options.project,
|
|
83
|
+
tsconfigRootDir: options.tsconfigRootDir,
|
|
84
|
+
allowDefaultProject: options.allowDefaultProject,
|
|
85
|
+
ecmaFeatures: { jsx: true },
|
|
61
86
|
},
|
|
62
87
|
},
|
|
63
|
-
plugins
|
|
88
|
+
plugins: {
|
|
89
|
+
import: importPlugin,
|
|
90
|
+
prettier: prettierPlugin,
|
|
91
|
+
security: securityPlugin,
|
|
92
|
+
'simple-import-sort': simpleImportSort,
|
|
93
|
+
'unused-imports': unusedImports,
|
|
94
|
+
},
|
|
64
95
|
rules: {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
'@typescript-eslint/no-unnecessary-type-arguments': 'error',
|
|
70
|
-
'@typescript-eslint/no-unnecessary-condition': 'error',
|
|
71
|
-
'@typescript-eslint/no-unnecessary-type-constraint': 'error',
|
|
72
|
-
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
|
|
73
|
-
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
|
|
74
|
-
'@typescript-eslint/prefer-nullish-coalescing': 'error',
|
|
75
|
-
'no-restricted-imports': [
|
|
76
|
-
'error',
|
|
77
|
-
{
|
|
78
|
-
paths: combinedRestrictedImports,
|
|
79
|
-
},
|
|
80
|
-
],
|
|
81
|
-
'prefer-destructuring': 'off',
|
|
82
|
-
'@typescript-eslint/prefer-destructuring': 'error',
|
|
83
|
-
'simple-import-sort/imports': 'error',
|
|
84
|
-
'simple-import-sort/exports': 'error',
|
|
85
|
-
'unused-imports/no-unused-imports': 'error',
|
|
86
|
-
'unused-imports/no-unused-vars': [
|
|
87
|
-
'error',
|
|
88
|
-
{
|
|
89
|
-
vars: 'all',
|
|
90
|
-
varsIgnorePattern: '^_',
|
|
91
|
-
args: 'after-used',
|
|
92
|
-
argsIgnorePattern: '^_',
|
|
93
|
-
},
|
|
94
|
-
],
|
|
95
|
-
'import/order': 'off',
|
|
96
|
-
'@typescript-eslint/no-unused-vars': 'off',
|
|
97
|
-
'@typescript-eslint/no-explicit-any': 'error',
|
|
98
|
-
'prettier/prettier': 'error',
|
|
99
|
-
'no-console': 'off',
|
|
96
|
+
...createTypeScriptRules(),
|
|
97
|
+
...createImportRules(options),
|
|
98
|
+
...createQualityRules(),
|
|
99
|
+
...createSecurityRules(),
|
|
100
100
|
},
|
|
101
|
-
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function createTypeScriptRules() {
|
|
104
|
+
return {
|
|
105
|
+
'@typescript-eslint/no-non-null-assertion': 'error',
|
|
106
|
+
'@typescript-eslint/prefer-readonly': 'error',
|
|
107
|
+
'@typescript-eslint/prefer-optional-chain': 'error',
|
|
108
|
+
'@typescript-eslint/prefer-as-const': 'error',
|
|
109
|
+
'@typescript-eslint/no-unnecessary-type-arguments': 'error',
|
|
110
|
+
'@typescript-eslint/no-unnecessary-condition': 'error',
|
|
111
|
+
'@typescript-eslint/no-unnecessary-type-constraint': 'error',
|
|
112
|
+
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
|
|
113
|
+
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
|
|
114
|
+
'@typescript-eslint/prefer-nullish-coalescing': 'error',
|
|
115
|
+
'@typescript-eslint/prefer-destructuring': 'error',
|
|
116
|
+
'@typescript-eslint/no-unused-vars': 'off',
|
|
117
|
+
'@typescript-eslint/no-explicit-any': 'error',
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function createImportRules(options) {
|
|
121
|
+
return {
|
|
122
|
+
'no-restricted-imports': [
|
|
123
|
+
'error',
|
|
124
|
+
{ paths: [...defaultRestrictedImports, ...options.restrictedImports] },
|
|
125
|
+
],
|
|
126
|
+
'prefer-destructuring': 'off',
|
|
127
|
+
'simple-import-sort/imports': 'error',
|
|
128
|
+
'simple-import-sort/exports': 'error',
|
|
129
|
+
'unused-imports/no-unused-imports': 'error',
|
|
130
|
+
'unused-imports/no-unused-vars': [
|
|
131
|
+
'error',
|
|
132
|
+
{ vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' },
|
|
133
|
+
],
|
|
134
|
+
'import/order': 'off',
|
|
135
|
+
'prettier/prettier': 'error',
|
|
136
|
+
'no-console': 'off',
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function createQualityRules() {
|
|
140
|
+
return {
|
|
141
|
+
'max-lines-per-function': ['error', { max: 50, skipBlankLines: true, skipComments: true }],
|
|
142
|
+
'max-lines': ['error', { max: 300, skipBlankLines: true, skipComments: true }],
|
|
143
|
+
complexity: ['error', { max: 15, variant: 'modified' }],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function createSecurityRules() {
|
|
147
|
+
return {
|
|
148
|
+
'security/detect-object-injection': 'warn',
|
|
149
|
+
'security/detect-non-literal-require': 'error',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function createProfileConfigs(profile, files) {
|
|
153
|
+
if (profile === 'base') {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
const reactConfig = createReactConfig(files);
|
|
157
|
+
return profile === 'react-native' ? [reactConfig, createReactNativeConfig(files)] : [reactConfig];
|
|
158
|
+
}
|
|
159
|
+
function createReactConfig(files) {
|
|
160
|
+
return {
|
|
161
|
+
files,
|
|
162
|
+
plugins: { react: reactPlugin, 'react-hooks': reactHooksPlugin },
|
|
163
|
+
settings: { react: { version: 'detect' } },
|
|
164
|
+
rules: {
|
|
165
|
+
'react/no-danger': 'error',
|
|
166
|
+
'react-hooks/rules-of-hooks': 'error',
|
|
167
|
+
'react-hooks/exhaustive-deps': 'error',
|
|
168
|
+
'react-hooks/immutability': 'error',
|
|
169
|
+
'react-hooks/purity': 'error',
|
|
170
|
+
'react-hooks/refs': 'error',
|
|
171
|
+
'react-hooks/set-state-in-effect': 'error',
|
|
172
|
+
'react-hooks/set-state-in-render': 'error',
|
|
173
|
+
'react-hooks/static-components': 'error',
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function createReactNativeConfig(files) {
|
|
178
|
+
return {
|
|
179
|
+
files,
|
|
180
|
+
plugins: { 'react-native': reactNativePlugin },
|
|
181
|
+
rules: {
|
|
182
|
+
'react-native/no-inline-styles': 'warn',
|
|
183
|
+
'react-native/no-unused-styles': 'error',
|
|
184
|
+
'react-native/no-single-element-style-arrays': 'error',
|
|
185
|
+
},
|
|
186
|
+
};
|
|
102
187
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const eslintManagedFiles: readonly [{
|
|
2
|
+
readonly relativePath: "eslint.local.config.mjs";
|
|
3
|
+
readonly render: typeof renderInitialLocalConfig;
|
|
4
|
+
readonly mode: "create-only";
|
|
5
|
+
}, {
|
|
6
|
+
readonly relativePath: "eslint.config.mjs";
|
|
7
|
+
readonly contents: "import { createConfig } from '@ankhorage/devtools/eslint';\nimport localConfig from './eslint.local.config.mjs';\n\nconst localEntries = Array.isArray(localConfig) ? localConfig : [localConfig];\n\nexport default [\n ...createConfig({\n files: ['src/**/*.{ts,tsx}'],\n project: ['./tsconfig.json'],\n tsconfigRootDir: import.meta.dirname,\n }),\n ...localEntries,\n];\n";
|
|
8
|
+
}];
|
|
9
|
+
declare function renderInitialLocalConfig(targetDirectory: string): Promise<string>;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
const ESLINT_CONFIG = `import { createConfig } from '@ankhorage/devtools/eslint';
|
|
4
|
+
import localConfig from './eslint.local.config.mjs';
|
|
5
|
+
|
|
6
|
+
const localEntries = Array.isArray(localConfig) ? localConfig : [localConfig];
|
|
7
|
+
|
|
8
|
+
export default [
|
|
9
|
+
...createConfig({
|
|
10
|
+
files: ['src/**/*.{ts,tsx}'],
|
|
11
|
+
project: ['./tsconfig.json'],
|
|
12
|
+
tsconfigRootDir: import.meta.dirname,
|
|
13
|
+
}),
|
|
14
|
+
...localEntries,
|
|
15
|
+
];
|
|
16
|
+
`;
|
|
17
|
+
const EMPTY_LOCAL_CONFIG = `export default [];
|
|
18
|
+
`;
|
|
19
|
+
export const eslintManagedFiles = [
|
|
20
|
+
{
|
|
21
|
+
relativePath: 'eslint.local.config.mjs',
|
|
22
|
+
render: renderInitialLocalConfig,
|
|
23
|
+
mode: 'create-only',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
relativePath: 'eslint.config.mjs',
|
|
27
|
+
contents: ESLINT_CONFIG,
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
async function renderInitialLocalConfig(targetDirectory) {
|
|
31
|
+
try {
|
|
32
|
+
const existingConfig = await readFile(resolve(targetDirectory, 'eslint.config.mjs'), 'utf8');
|
|
33
|
+
return existingConfig === ESLINT_CONFIG ? EMPTY_LOCAL_CONFIG : existingConfig;
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (isNodeError(error) && error.code === 'ENOENT') {
|
|
37
|
+
return EMPTY_LOCAL_CONFIG;
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function isNodeError(error) {
|
|
43
|
+
return error instanceof Error && 'code' in error;
|
|
44
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type ProjectDetectionInput } from '@ankhorage/utility/project';
|
|
2
|
+
import type { DevtoolsConfigOptions, DevtoolsEslintProfile, ResolvedDevtoolsEslintProfile } from './types.js';
|
|
3
|
+
export declare function resolveEslintProfile(options: DevtoolsConfigOptions): ResolvedDevtoolsEslintProfile;
|
|
4
|
+
export declare function resolveEslintProfileFromDetectionInput(requestedProfile: DevtoolsEslintProfile, input: ProjectDetectionInput): ResolvedDevtoolsEslintProfile;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { detectProject, } from '@ankhorage/utility/project';
|
|
4
|
+
export function resolveEslintProfile(options) {
|
|
5
|
+
const requestedProfile = options.profile ?? 'auto';
|
|
6
|
+
if (requestedProfile !== 'auto') {
|
|
7
|
+
return requestedProfile;
|
|
8
|
+
}
|
|
9
|
+
const packageJsonPath = resolveProjectPackageJsonPath(options);
|
|
10
|
+
const input = packageJsonPath === null ? {} : readDetectionInput(packageJsonPath);
|
|
11
|
+
return resolveEslintProfileFromDetectionInput('auto', input);
|
|
12
|
+
}
|
|
13
|
+
export function resolveEslintProfileFromDetectionInput(requestedProfile, input) {
|
|
14
|
+
if (requestedProfile !== 'auto') {
|
|
15
|
+
return requestedProfile;
|
|
16
|
+
}
|
|
17
|
+
const { traits } = detectProject(input);
|
|
18
|
+
if (traits.has('react-native') || traits.has('expo')) {
|
|
19
|
+
return 'react-native';
|
|
20
|
+
}
|
|
21
|
+
return traits.has('react') ? 'react' : 'base';
|
|
22
|
+
}
|
|
23
|
+
function resolveProjectPackageJsonPath(options) {
|
|
24
|
+
if (options.packageJsonPath !== undefined) {
|
|
25
|
+
return resolve(options.tsconfigRootDir, options.packageJsonPath);
|
|
26
|
+
}
|
|
27
|
+
let directory = resolve(options.tsconfigRootDir);
|
|
28
|
+
for (;;) {
|
|
29
|
+
const candidate = resolve(directory, 'package.json');
|
|
30
|
+
if (existsSync(candidate)) {
|
|
31
|
+
return candidate;
|
|
32
|
+
}
|
|
33
|
+
const parent = dirname(directory);
|
|
34
|
+
if (parent === directory) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
directory = parent;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function readDetectionInput(packageJsonPath) {
|
|
41
|
+
const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
42
|
+
if (!isRecord(parsed)) {
|
|
43
|
+
throw new Error(`Expected package.json to contain a JSON object: ${packageJsonPath}`);
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
...optionalDependencyMap('dependencies', parsed.dependencies),
|
|
47
|
+
...optionalDependencyMap('devDependencies', parsed.devDependencies),
|
|
48
|
+
...optionalDependencyMap('peerDependencies', parsed.peerDependencies),
|
|
49
|
+
...optionalEngines(parsed.engines),
|
|
50
|
+
...(typeof parsed.packageManager === 'string' ? { packageManager: parsed.packageManager } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function optionalDependencyMap(key, value) {
|
|
54
|
+
const dependencyMap = toDependencyMap(value);
|
|
55
|
+
return dependencyMap === undefined ? {} : { [key]: dependencyMap };
|
|
56
|
+
}
|
|
57
|
+
function toDependencyMap(value) {
|
|
58
|
+
if (!isRecord(value)) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const dependencies = {};
|
|
62
|
+
for (const [name, version] of Object.entries(value)) {
|
|
63
|
+
if (typeof version === 'string') {
|
|
64
|
+
dependencies[name] = version;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return dependencies;
|
|
68
|
+
}
|
|
69
|
+
function optionalEngines(value) {
|
|
70
|
+
if (!isRecord(value)) {
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
const engines = {
|
|
74
|
+
...(typeof value.bun === 'string' ? { bun: value.bun } : {}),
|
|
75
|
+
...(typeof value.node === 'string' ? { node: value.node } : {}),
|
|
76
|
+
};
|
|
77
|
+
return Object.keys(engines).length === 0 ? {} : { engines };
|
|
78
|
+
}
|
|
79
|
+
function isRecord(value) {
|
|
80
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
81
|
+
}
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import type tseslint from 'typescript-eslint';
|
|
2
2
|
type FlatConfig = ReturnType<typeof tseslint.config>;
|
|
3
3
|
export type FlatConfigItem = FlatConfig[number];
|
|
4
|
-
interface RestrictedImport {
|
|
4
|
+
export interface RestrictedImport {
|
|
5
5
|
readonly name: string;
|
|
6
6
|
readonly message: string;
|
|
7
7
|
}
|
|
8
|
+
export type DevtoolsEslintProfile = 'auto' | 'base' | 'react' | 'react-native';
|
|
9
|
+
export type ResolvedDevtoolsEslintProfile = Exclude<DevtoolsEslintProfile, 'auto'>;
|
|
8
10
|
export interface DevtoolsConfigOptions {
|
|
9
11
|
readonly tsconfigRootDir: string;
|
|
10
12
|
readonly project: string[];
|
|
11
13
|
readonly files: string[];
|
|
14
|
+
readonly profile?: DevtoolsEslintProfile;
|
|
15
|
+
readonly packageJsonPath?: string;
|
|
12
16
|
readonly allowDefaultProject?: string[];
|
|
13
17
|
readonly additionalIgnores?: string[];
|
|
14
18
|
readonly restrictedImports?: RestrictedImport[];
|
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/***
|
|
2
|
+
* Build shared Knip configuration while preserving repository-specific discovery.
|
|
3
|
+
*
|
|
4
|
+
* `createKnipConfig` keeps Knip zero-config behavior by default and only emits settings that the
|
|
5
|
+
* consumer explicitly provides. Repositories can add narrow entries, project globs, ignored files,
|
|
6
|
+
* binaries, dependencies, and workspace overrides without duplicating the shared Knip version.
|
|
7
|
+
*
|
|
8
|
+
* `createKnipMonorepoConfig` adds a root workspace plus conventional `packages/*` and `apps/*`
|
|
9
|
+
* workspace globs. Callers can override those globs, provide workspace defaults, and replace or
|
|
10
|
+
* extend individual workspace configuration deterministically.
|
|
11
|
+
*
|
|
12
|
+
* @readme
|
|
13
|
+
*/
|
|
1
14
|
import type { KnipConfig } from 'knip';
|
|
2
15
|
export interface DevtoolsKnipWorkspaceConfigOptions {
|
|
3
16
|
readonly entry?: string[];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const KNIP_CONFIG = `import { createKnipConfig } from '@ankhorage/devtools/knip';
|
|
2
|
+
|
|
3
|
+
export default createKnipConfig();
|
|
4
|
+
`;
|
|
5
|
+
export const knipManagedFiles = [
|
|
6
|
+
{
|
|
7
|
+
relativePath: 'knip.config.ts',
|
|
8
|
+
contents: KNIP_CONFIG,
|
|
9
|
+
mode: 'create-only',
|
|
10
|
+
},
|
|
11
|
+
];
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ManagedFileStatus, ManagedFileSyncResult } from '../shared/managedFiles.js';
|
|
2
|
+
export declare function inspectPackageManifest(targetDirectory: string, devtoolsVersion: string): Promise<ManagedFileStatus>;
|
|
3
|
+
export declare function syncPackageManifest(targetDirectory: string, devtoolsVersion: string, options: {
|
|
4
|
+
readonly dryRun: boolean;
|
|
5
|
+
}): Promise<ManagedFileSyncResult>;
|
|
6
|
+
export declare function applyManagedPackageContract(manifest: Record<string, unknown>, devtoolsVersion: string): Record<string, unknown>;
|
|
7
|
+
export declare function isManagedPackageContractCurrent(manifest: Record<string, unknown>, devtoolsVersion: string): boolean;
|
|
8
|
+
export declare function readCurrentDevtoolsVersion(): string;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/***
|
|
2
|
+
* Synchronize the consumer `package.json` contract owned by `@ankhorage/devtools`.
|
|
3
|
+
*
|
|
4
|
+
* Package synchronization is merge-aware. It installs the current `@ankhorage/devtools` version
|
|
5
|
+
* as a development dependency for normal consumers, while `@ankhorage/ankh` keeps devtools as a
|
|
6
|
+
* runtime dependency because the CLI loads it as a bundled core provider. Individually installed
|
|
7
|
+
* toolchain packages that devtools now owns are removed, and the canonical `lint`, `lint:fix`,
|
|
8
|
+
* `format`, `format:check`, and `knip` scripts are written.
|
|
9
|
+
* Unrelated manifest fields, scripts, dependencies, and metadata are preserved.
|
|
10
|
+
*
|
|
11
|
+
* Status compares only the fields owned by this contract, so unrelated repository customization
|
|
12
|
+
* does not count as drift. `--dry-run` reports whether `package.json` would be created or updated
|
|
13
|
+
* without writing it, and repeated synchronization is idempotent.
|
|
14
|
+
*
|
|
15
|
+
* The devtools package itself is excluded from the consumer contract to avoid rewriting its own
|
|
16
|
+
* manifest.
|
|
17
|
+
*
|
|
18
|
+
* @readme
|
|
19
|
+
*/
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
22
|
+
import { resolve } from 'node:path';
|
|
23
|
+
const PACKAGE_PATH = 'package.json';
|
|
24
|
+
const DEVTOOLS_PACKAGE_NAME = '@ankhorage/devtools';
|
|
25
|
+
const ANKH_PACKAGE_NAME = '@ankhorage/ankh';
|
|
26
|
+
const STANDARD_SCRIPTS = {
|
|
27
|
+
lint: 'ankhorage-eslint . --max-warnings=0',
|
|
28
|
+
'lint:fix': 'ankhorage-eslint . --fix --max-warnings=0',
|
|
29
|
+
format: 'ankhorage-prettier --write .',
|
|
30
|
+
'format:check': 'ankhorage-prettier --check .',
|
|
31
|
+
knip: 'ankhorage-knip',
|
|
32
|
+
};
|
|
33
|
+
const DEVTOOLS_OWNED_DEV_DEPENDENCIES = [
|
|
34
|
+
'@eslint/js',
|
|
35
|
+
'eslint',
|
|
36
|
+
'eslint-config-prettier',
|
|
37
|
+
'eslint-plugin-import',
|
|
38
|
+
'eslint-plugin-prettier',
|
|
39
|
+
'eslint-plugin-react',
|
|
40
|
+
'eslint-plugin-react-hooks',
|
|
41
|
+
'eslint-plugin-react-native',
|
|
42
|
+
'eslint-plugin-security',
|
|
43
|
+
'eslint-plugin-simple-import-sort',
|
|
44
|
+
'eslint-plugin-unused-imports',
|
|
45
|
+
'knip',
|
|
46
|
+
'prettier',
|
|
47
|
+
'typescript-eslint',
|
|
48
|
+
];
|
|
49
|
+
export async function inspectPackageManifest(targetDirectory, devtoolsVersion) {
|
|
50
|
+
const snapshot = await readPackageManifest(targetDirectory);
|
|
51
|
+
if (!snapshot.exists) {
|
|
52
|
+
return { relativePath: PACKAGE_PATH, state: 'missing' };
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
relativePath: PACKAGE_PATH,
|
|
56
|
+
state: isManagedPackageContractCurrent(snapshot.manifest, devtoolsVersion)
|
|
57
|
+
? 'current'
|
|
58
|
+
: 'outdated',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export async function syncPackageManifest(targetDirectory, devtoolsVersion, options) {
|
|
62
|
+
const snapshot = await readPackageManifest(targetDirectory);
|
|
63
|
+
if (snapshot.exists && isManagedPackageContractCurrent(snapshot.manifest, devtoolsVersion)) {
|
|
64
|
+
return { relativePath: PACKAGE_PATH, action: 'unchanged' };
|
|
65
|
+
}
|
|
66
|
+
if (options.dryRun) {
|
|
67
|
+
return {
|
|
68
|
+
relativePath: PACKAGE_PATH,
|
|
69
|
+
action: snapshot.exists ? 'would-update' : 'would-create',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const updatedManifest = applyManagedPackageContract(snapshot.manifest, devtoolsVersion);
|
|
73
|
+
await writeFile(resolve(targetDirectory, PACKAGE_PATH), serializePackageManifest(updatedManifest), 'utf8');
|
|
74
|
+
return {
|
|
75
|
+
relativePath: PACKAGE_PATH,
|
|
76
|
+
action: snapshot.exists ? 'updated' : 'created',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export function applyManagedPackageContract(manifest, devtoolsVersion) {
|
|
80
|
+
if (manifest.name === DEVTOOLS_PACKAGE_NAME) {
|
|
81
|
+
return manifest;
|
|
82
|
+
}
|
|
83
|
+
const scripts = { ...toRecord(manifest.scripts), ...STANDARD_SCRIPTS };
|
|
84
|
+
const devDependencies = removeOwnedDependencies(toRecord(manifest.devDependencies));
|
|
85
|
+
const dependencies = toRecord(manifest.dependencies);
|
|
86
|
+
applyDevtoolsDependencyPlacement(manifest, dependencies, devDependencies, devtoolsVersion);
|
|
87
|
+
return {
|
|
88
|
+
...manifest,
|
|
89
|
+
...normalizedDependencies(manifest, dependencies),
|
|
90
|
+
scripts,
|
|
91
|
+
devDependencies,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export function isManagedPackageContractCurrent(manifest, devtoolsVersion) {
|
|
95
|
+
if (manifest.name === DEVTOOLS_PACKAGE_NAME) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
const scripts = toRecord(manifest.scripts);
|
|
99
|
+
const devDependencies = toRecord(manifest.devDependencies);
|
|
100
|
+
const dependencies = toRecord(manifest.dependencies);
|
|
101
|
+
return (hasStandardScripts(scripts) &&
|
|
102
|
+
DEVTOOLS_OWNED_DEV_DEPENDENCIES.every((name) => devDependencies[name] === undefined) &&
|
|
103
|
+
hasCurrentDevtoolsDependencyPlacement(manifest, dependencies, devDependencies, devtoolsVersion));
|
|
104
|
+
}
|
|
105
|
+
export function readCurrentDevtoolsVersion() {
|
|
106
|
+
const parsed = JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'));
|
|
107
|
+
if (!isRecord(parsed) || typeof parsed.version !== 'string' || parsed.version.trim() === '') {
|
|
108
|
+
throw new Error('Devtools package.json must define a non-empty version string.');
|
|
109
|
+
}
|
|
110
|
+
return parsed.version;
|
|
111
|
+
}
|
|
112
|
+
async function readPackageManifest(targetDirectory) {
|
|
113
|
+
try {
|
|
114
|
+
const contents = await readFile(resolve(targetDirectory, PACKAGE_PATH), 'utf8');
|
|
115
|
+
const parsed = JSON.parse(contents);
|
|
116
|
+
if (!isRecord(parsed)) {
|
|
117
|
+
throw new Error(`Expected ${PACKAGE_PATH} to contain a JSON object.`);
|
|
118
|
+
}
|
|
119
|
+
return { exists: true, manifest: parsed };
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (isNodeError(error) && error.code === 'ENOENT') {
|
|
123
|
+
return { exists: false, manifest: {} };
|
|
124
|
+
}
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function applyDevtoolsDependencyPlacement(manifest, dependencies, devDependencies, devtoolsVersion) {
|
|
129
|
+
const versionRange = `^${devtoolsVersion}`;
|
|
130
|
+
if (manifest.name === ANKH_PACKAGE_NAME) {
|
|
131
|
+
dependencies[DEVTOOLS_PACKAGE_NAME] = versionRange;
|
|
132
|
+
delete devDependencies[DEVTOOLS_PACKAGE_NAME];
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
devDependencies[DEVTOOLS_PACKAGE_NAME] = versionRange;
|
|
136
|
+
delete dependencies[DEVTOOLS_PACKAGE_NAME];
|
|
137
|
+
}
|
|
138
|
+
function hasCurrentDevtoolsDependencyPlacement(manifest, dependencies, devDependencies, devtoolsVersion) {
|
|
139
|
+
const versionRange = `^${devtoolsVersion}`;
|
|
140
|
+
if (manifest.name === ANKH_PACKAGE_NAME) {
|
|
141
|
+
return (dependencies[DEVTOOLS_PACKAGE_NAME] === versionRange &&
|
|
142
|
+
devDependencies[DEVTOOLS_PACKAGE_NAME] === undefined);
|
|
143
|
+
}
|
|
144
|
+
return (devDependencies[DEVTOOLS_PACKAGE_NAME] === versionRange &&
|
|
145
|
+
dependencies[DEVTOOLS_PACKAGE_NAME] === undefined);
|
|
146
|
+
}
|
|
147
|
+
function removeOwnedDependencies(devDependencies) {
|
|
148
|
+
for (const dependencyName of DEVTOOLS_OWNED_DEV_DEPENDENCIES) {
|
|
149
|
+
delete devDependencies[dependencyName];
|
|
150
|
+
}
|
|
151
|
+
return devDependencies;
|
|
152
|
+
}
|
|
153
|
+
function normalizedDependencies(manifest, dependencies) {
|
|
154
|
+
return Object.keys(dependencies).length === 0 && manifest.dependencies === undefined
|
|
155
|
+
? {}
|
|
156
|
+
: { dependencies };
|
|
157
|
+
}
|
|
158
|
+
function hasStandardScripts(scripts) {
|
|
159
|
+
return Object.entries(STANDARD_SCRIPTS).every(([name, command]) => scripts[name] === command);
|
|
160
|
+
}
|
|
161
|
+
function serializePackageManifest(manifest) {
|
|
162
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
163
|
+
}
|
|
164
|
+
function toRecord(value) {
|
|
165
|
+
return isRecord(value) ? { ...value } : {};
|
|
166
|
+
}
|
|
167
|
+
function isRecord(value) {
|
|
168
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
169
|
+
}
|
|
170
|
+
function isNodeError(error) {
|
|
171
|
+
return error instanceof Error && 'code' in error;
|
|
172
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
const ESM_CONFIG = `export { default } from '@ankhorage/devtools/prettier';
|
|
4
|
+
`;
|
|
5
|
+
const COMMONJS_CONFIG = `module.exports = require('@ankhorage/devtools/prettier');
|
|
6
|
+
`;
|
|
7
|
+
export const prettierManagedFiles = [
|
|
8
|
+
{
|
|
9
|
+
relativePath: '.prettierrc.js',
|
|
10
|
+
render: renderPrettierConfig,
|
|
11
|
+
},
|
|
12
|
+
];
|
|
13
|
+
async function renderPrettierConfig(targetDirectory) {
|
|
14
|
+
return (await readPackageType(targetDirectory)) === 'module' ? ESM_CONFIG : COMMONJS_CONFIG;
|
|
15
|
+
}
|
|
16
|
+
async function readPackageType(targetDirectory) {
|
|
17
|
+
try {
|
|
18
|
+
const contents = await readFile(resolve(targetDirectory, 'package.json'), 'utf8');
|
|
19
|
+
const parsed = JSON.parse(contents);
|
|
20
|
+
return isRecord(parsed) && typeof parsed.type === 'string' ? parsed.type : undefined;
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (isNodeError(error) && error.code === 'ENOENT') {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function isRecord(value) {
|
|
30
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
function isNodeError(error) {
|
|
33
|
+
return error instanceof Error && 'code' in error;
|
|
34
|
+
}
|