[eslint] add rule for validating cross-boundary imports (#137116)

This commit is contained in:
Spencer 2022-07-25 18:49:17 -05:00 committed by GitHub
parent 88bb91f3a0
commit 20f9cf9fd4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 1672 additions and 64 deletions

View file

@ -22,6 +22,7 @@ layout: landing
<DocRelatedArticles
sectionTitle='Build tooling'
items={[
{ pageId: "kibDevDocsOpsKbnPm" },
{ pageId: "kibDevDocsOpsOptimizer" },
{ pageId: "kibDevDocsOpsBabelPreset" },
{ pageId: "kibDevDocsOpsTypeSummarizer" },
@ -36,6 +37,7 @@ layout: landing
sectionTitle='Linting & Validation'
items={[
{ pageId: "kibDevDocsOpsEslintConfig" },
{ pageId: "kibDevDocsOpsEslintPluginEslint" },
{ pageId: "kibDevDocsOpsEslintWithTypes" },
{ pageId: "kibDevDocsOpsEslintPluginImports" },
{ pageId: "kibDevDocsOpsEslintPluginDisable" },
@ -55,5 +57,9 @@ layout: landing
{ pageId: "kibDevDocsOpsBazelRunner" },
{ pageId: "kibDevDocsOpsCliDevMode" },
{ pageId: "kibDevDocsOpsEs" },
{ pageId: "kibDevDocsOpsSomeDevLog" },
{ pageId: "kibDevDocsOpsDevCliRunner" },
{ pageId: "kibDevDocsOpsGetRepoFiles" },
{ pageId: "kibDevDocsOpsRepoSourceClassifier" },
]}
/>

View file

@ -531,6 +531,12 @@
},
{
"id": "kibDevDocsOpsDevCliRunner"
},
{
"id": "kibDevDocsOpsGetRepoFiles"
},
{
"id": "kibDevDocsOpsRepoSourceClassifier"
}
]
}

View file

@ -604,6 +604,7 @@
"@kbn/expect": "link:bazel-bin/packages/kbn-expect",
"@kbn/find-used-node-modules": "link:bazel-bin/packages/kbn-find-used-node-modules",
"@kbn/generate": "link:bazel-bin/packages/kbn-generate",
"@kbn/get-repo-files": "link:bazel-bin/packages/kbn-get-repo-files",
"@kbn/import-resolver": "link:bazel-bin/packages/kbn-import-resolver",
"@kbn/jest-serializers": "link:bazel-bin/packages/kbn-jest-serializers",
"@kbn/optimizer": "link:bazel-bin/packages/kbn-optimizer",
@ -611,6 +612,8 @@
"@kbn/performance-testing-dataset-extractor": "link:bazel-bin/packages/kbn-performance-testing-dataset-extractor",
"@kbn/plugin-generator": "link:bazel-bin/packages/kbn-plugin-generator",
"@kbn/plugin-helpers": "link:bazel-bin/packages/kbn-plugin-helpers",
"@kbn/repo-source-classifier": "link:bazel-bin/packages/kbn-repo-source-classifier",
"@kbn/repo-source-classifier-cli": "link:bazel-bin/packages/kbn-repo-source-classifier-cli",
"@kbn/scalability-simulation-generator": "link:bazel-bin/packages/kbn-scalability-simulation-generator",
"@kbn/some-dev-log": "link:bazel-bin/packages/kbn-some-dev-log",
"@kbn/sort-package-json": "link:bazel-bin/packages/kbn-sort-package-json",
@ -851,6 +854,7 @@
"@types/kbn__field-types": "link:bazel-bin/packages/kbn-field-types/npm_module_types",
"@types/kbn__find-used-node-modules": "link:bazel-bin/packages/kbn-find-used-node-modules/npm_module_types",
"@types/kbn__generate": "link:bazel-bin/packages/kbn-generate/npm_module_types",
"@types/kbn__get-repo-files": "link:bazel-bin/packages/kbn-get-repo-files/npm_module_types",
"@types/kbn__handlebars": "link:bazel-bin/packages/kbn-handlebars/npm_module_types",
"@types/kbn__hapi-mocks": "link:bazel-bin/packages/kbn-hapi-mocks/npm_module_types",
"@types/kbn__home-sample-data-cards": "link:bazel-bin/packages/home/sample_data_cards/npm_module_types",
@ -876,6 +880,8 @@
"@types/kbn__plugin-generator": "link:bazel-bin/packages/kbn-plugin-generator/npm_module_types",
"@types/kbn__plugin-helpers": "link:bazel-bin/packages/kbn-plugin-helpers/npm_module_types",
"@types/kbn__react-field": "link:bazel-bin/packages/kbn-react-field/npm_module_types",
"@types/kbn__repo-source-classifier": "link:bazel-bin/packages/kbn-repo-source-classifier/npm_module_types",
"@types/kbn__repo-source-classifier-cli": "link:bazel-bin/packages/kbn-repo-source-classifier-cli/npm_module_types",
"@types/kbn__rule-data-utils": "link:bazel-bin/packages/kbn-rule-data-utils/npm_module_types",
"@types/kbn__scalability-simulation-generator": "link:bazel-bin/packages/kbn-scalability-simulation-generator/npm_module_types",
"@types/kbn__securitysolution-autocomplete": "link:bazel-bin/packages/kbn-securitysolution-autocomplete/npm_module_types",

View file

@ -145,6 +145,7 @@ filegroup(
"//packages/kbn-find-used-node-modules:build",
"//packages/kbn-flot-charts:build",
"//packages/kbn-generate:build",
"//packages/kbn-get-repo-files:build",
"//packages/kbn-handlebars:build",
"//packages/kbn-hapi-mocks:build",
"//packages/kbn-i18n-react:build",
@ -165,6 +166,8 @@ filegroup(
"//packages/kbn-plugin-generator:build",
"//packages/kbn-plugin-helpers:build",
"//packages/kbn-react-field:build",
"//packages/kbn-repo-source-classifier-cli:build",
"//packages/kbn-repo-source-classifier:build",
"//packages/kbn-rule-data-utils:build",
"//packages/kbn-scalability-simulation-generator:build",
"//packages/kbn-securitysolution-autocomplete:build",
@ -361,6 +364,7 @@ filegroup(
"//packages/kbn-field-types:build_types",
"//packages/kbn-find-used-node-modules:build_types",
"//packages/kbn-generate:build_types",
"//packages/kbn-get-repo-files:build_types",
"//packages/kbn-handlebars:build_types",
"//packages/kbn-hapi-mocks:build_types",
"//packages/kbn-i18n-react:build_types",
@ -381,6 +385,8 @@ filegroup(
"//packages/kbn-plugin-generator:build_types",
"//packages/kbn-plugin-helpers:build_types",
"//packages/kbn-react-field:build_types",
"//packages/kbn-repo-source-classifier-cli:build_types",
"//packages/kbn-repo-source-classifier:build_types",
"//packages/kbn-rule-data-utils:build_types",
"//packages/kbn-scalability-simulation-generator:build_types",
"//packages/kbn-securitysolution-autocomplete:build_types",

View file

@ -57,6 +57,7 @@ TYPES_DEPS = [
"//packages/kbn-utils:npm_module_types",
"//packages/kbn-dev-utils:npm_module_types", # only required for the tests, which are excluded except on windows
"//packages/kbn-import-resolver:npm_module_types",
"//packages/kbn-repo-source-classifier:npm_module_types",
"@npm//dedent", # only required for the tests, which are excluded except on windows
"@npm//@types/eslint",
"@npm//@types/jest",

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ImportResolver } from '@kbn/import-resolver';
import { RepoSourceClassifier } from '@kbn/repo-source-classifier';
const cache = new WeakMap<ImportResolver, RepoSourceClassifier>();
/**
* Gets the instance of RepoSourceClassifier that should be used. We cache these instances
* key'd off of ImportResolver instances because the caches maintained by the RepoSourceClassifer
* should live the same amount of time. Both classes assume that the files on disk are
* relatively "stable" for the lifetime of the object and once the files are assumed
* to have change that a new object will be created and the old version with the old
* caches will be thrown away and garbage collected.
*/
export function getRepoSourceClassifier(resolver: ImportResolver) {
const cached = cache.get(resolver);
if (cached) {
return cached;
}
const classifier = new RepoSourceClassifier(resolver);
cache.set(resolver, classifier);
return classifier;
}

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Rule } from 'eslint';
/**
* Get the path of the sourcefile being linted
*/
export function getSourcePath(context: Rule.RuleContext) {
const sourceFilename = context.getPhysicalFilename
? context.getPhysicalFilename()
: context.getFilename();
if (!sourceFilename) {
throw new Error('unable to determine sourceFilename for file being linted');
}
return sourceFilename;
}

View file

@ -11,6 +11,7 @@ import { NoUnresolvableImportsRule } from './rules/no_unresolvable_imports';
import { UniformImportsRule } from './rules/uniform_imports';
import { ExportsMovedPackagesRule } from './rules/exports_moved_packages';
import { NoUnusedImportsRule } from './rules/no_unused_imports';
import { NoBoundaryCrossingRule } from './rules/no_boundary_crossing';
/**
* Custom ESLint rules, add `'@kbn/eslint-plugin-imports'` to your eslint config to use them
@ -21,4 +22,5 @@ export const rules = {
uniform_imports: UniformImportsRule,
exports_moved_packages: ExportsMovedPackagesRule,
no_unused_imports: NoUnusedImportsRule,
no_boundary_crossing: NoBoundaryCrossingRule,
};

View file

@ -220,7 +220,7 @@ export const ExportsMovedPackagesRule: Rule.RuleModule = {
},
],
docs: {
url: 'https://github.com/elastic/kibana/blob/main/packages/kbn-eslint-plugin-imports/README.md#kbnimportsexports_moved_packages',
url: 'https://github.com/elastic/kibana/blob/main/packages/kbn-eslint-plugin-imports/README.mdx#kbnimportsexports_moved_packages',
},
},

View file

@ -0,0 +1,159 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { RuleTester } from 'eslint';
import { NoBoundaryCrossingRule } from './no_boundary_crossing';
import { ModuleType } from '@kbn/repo-source-classifier';
import dedent from 'dedent';
const make = (from: ModuleType, to: ModuleType, imp = 'import') => ({
filename: `${from}.ts`,
code: dedent`
${imp} '${to}'
`,
});
jest.mock('../get_import_resolver', () => {
return {
getImportResolver() {
return {
resolve(req: string) {
return {
type: 'file',
absolute: {
type: req,
},
};
},
};
},
};
});
jest.mock('../helpers/repo_source_classifier', () => {
return {
getRepoSourceClassifier() {
return {
classify(r: string | { type: string }) {
return {
type: typeof r === 'string' ? (r.endsWith('.ts') ? r.slice(0, -3) : r) : r.type,
};
},
};
},
};
});
const tsTester = [
'@typescript-eslint/parser',
new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
sourceType: 'module',
ecmaVersion: 2018,
ecmaFeatures: {
jsx: true,
},
},
}),
] as const;
const babelTester = [
'@babel/eslint-parser',
new RuleTester({
parser: require.resolve('@babel/eslint-parser'),
parserOptions: {
sourceType: 'module',
ecmaVersion: 2018,
requireConfigFile: false,
babelOptions: {
presets: ['@kbn/babel-preset/node_preset'],
},
},
}),
] as const;
for (const [name, tester] of [tsTester, babelTester]) {
describe(name, () => {
tester.run('@kbn/imports/no_boundary_crossing', NoBoundaryCrossingRule, {
valid: [
make('common package', 'common package'),
make('server package', 'common package'),
make('browser package', 'common package'),
make('server package', 'server package'),
make('browser package', 'browser package'),
make('tests or mocks', 'common package'),
make('tests or mocks', 'browser package'),
make('tests or mocks', 'server package'),
make('tests or mocks', 'tests or mocks'),
make('browser package', 'server package', 'import type { Foo } from'),
make('server package', 'browser package', 'import type { Foo } from'),
make('common package', 'browser package', 'import type { Foo } from'),
],
invalid: [
{
...make('common package', 'server package'),
errors: [
{
line: 1,
messageId: 'TYPE_MISMATCH',
data: {
importedType: 'server package',
ownType: 'common package',
suggestion: ` ${dedent`
Suggestions:
- Remove the import statement.
- Limit your imports to "common package" or "static" code.
- Covert to a type-only import.
- Reach out to #kibana-operations for help.
`}`,
},
},
],
},
{
...make('server package', 'tests or mocks'),
errors: [
{
line: 1,
messageId: 'TYPE_MISMATCH',
},
],
},
{
...make('browser package', 'tests or mocks'),
errors: [
{
line: 1,
messageId: 'TYPE_MISMATCH',
},
],
},
{
...make('common package', 'server package'),
errors: [
{
line: 1,
messageId: 'TYPE_MISMATCH',
},
],
},
{
...make('common package', 'browser package'),
errors: [
{
line: 1,
messageId: 'TYPE_MISMATCH',
},
],
},
],
});
});
}

View file

@ -0,0 +1,146 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import Path from 'path';
import { TSESTree } from '@typescript-eslint/typescript-estree';
import * as Bt from '@babel/types';
import { Rule } from 'eslint';
import ESTree from 'estree';
import { ModuleType } from '@kbn/repo-source-classifier';
import { visitAllImportStatements, Importer } from '../helpers/visit_all_import_statements';
import { getSourcePath } from '../helpers/source';
import { getRepoSourceClassifier } from '../helpers/repo_source_classifier';
import { getImportResolver } from '../get_import_resolver';
const IMPORTABLE_FROM: Record<ModuleType, ModuleType[] | '*'> = {
'non-package': ['non-package', 'server package', 'browser package', 'common package', 'static'],
'server package': ['common package', 'server package', 'static'],
'browser package': ['common package', 'browser package', 'static'],
'common package': ['common package', 'static'],
static: [],
'tests or mocks': '*',
tooling: '*',
};
const toList = (strings: string[]) => {
const items = strings.map((s) => `"${s}"`);
const list = items.slice(0, -1).join(', ');
const last = items.at(-1);
return !list.length ? last ?? '' : `${list} or ${last}`;
};
const formatSuggestions = (suggestions: string[]) => {
const s = suggestions.map((l) => l.trim()).filter(Boolean);
if (!s.length) {
return '';
}
return ` Suggestions:\n - ${s.join('\n - ')}`;
};
const isTypeOnlyImport = (importer: Importer) => {
// handle babel nodes
if (Bt.isImportDeclaration(importer)) {
return (
importer.importKind === 'type' ||
importer.specifiers.some((s) => ('importKind' in s ? s.importKind === 'type' : false))
);
}
if (importer.type === TSESTree.AST_NODE_TYPES.ImportDeclaration) {
return (
importer.importKind === 'type' ||
importer.specifiers.some(
(s) => s.type === TSESTree.AST_NODE_TYPES.ImportSpecifier && s.importKind === 'type'
)
);
}
if (Bt.isExportNamedDeclaration(importer)) {
return (
importer.exportKind === 'type' ||
importer.specifiers.some((s) => (Bt.isExportSpecifier(s) ? s.exportKind === 'type' : false))
);
}
if (importer.type === TSESTree.AST_NODE_TYPES.ExportNamedDeclaration) {
return (
importer.exportKind === 'type' || importer.specifiers.some((s) => s.exportKind === 'type')
);
}
return false;
};
export const NoBoundaryCrossingRule: Rule.RuleModule = {
meta: {
docs: {
url: 'https://github.com/elastic/kibana/blob/main/packages/kbn-eslint-plugin-imports/README.mdx#kbnimportsno_unused_imports',
},
messages: {
TYPE_MISMATCH: `"{{importedType}}" code can not be imported from "{{ownType}}" code.{{suggestion}}`,
},
},
create(context) {
const resolver = getImportResolver(context);
const classifier = getRepoSourceClassifier(resolver);
const sourcePath = getSourcePath(context);
const ownDirname = Path.dirname(sourcePath);
const self = classifier.classify(sourcePath);
const importable = IMPORTABLE_FROM[self.type];
if (importable === '*') {
// don't check imports in files which can import anything
return {};
}
return visitAllImportStatements((req, { node, importer }) => {
if (
req === null ||
// we can ignore imports using the raw-loader, they will need to be resolved but can be managed on a case by case basis
req.startsWith('!!raw-loader') ||
// type only imports can stretch across all the boundaries
isTypeOnlyImport(importer)
) {
return;
}
const result = resolver.resolve(req, ownDirname);
if (result?.type !== 'file' || result.nodeModule) {
return;
}
const imported = classifier.classify(result.absolute);
if (!importable.includes(imported.type)) {
context.report({
node: node as ESTree.Node,
messageId: 'TYPE_MISMATCH',
data: {
ownType: self.type,
importedType: imported.type,
suggestion: formatSuggestions([
self.type.endsWith(' package') && imported.type === 'tests or mocks'
? 'To expose mocks to other packages, they should be in their own package that is consumed by this package.'
: '',
`Remove the import statement.`,
importable.length > 0 ? `Limit your imports to ${toList(importable)} code.` : '',
`Covert to a type-only import.`,
`Reach out to #kibana-operations for help.`,
]),
},
});
return;
}
});
},
};

View file

@ -10,28 +10,22 @@ import Path from 'path';
import { Rule } from 'eslint';
import { report } from '../helpers/report';
import { getSourcePath } from '../helpers/source';
import { getImportResolver } from '../get_import_resolver';
import { visitAllImportStatements } from '../helpers/visit_all_import_statements';
export const NoUnresolvableImportsRule: Rule.RuleModule = {
meta: {
docs: {
url: 'https://github.com/elastic/kibana/blob/main/packages/kbn-eslint-plugin-imports/README.md#kbnimportsno_unresolvable_imports',
url: 'https://github.com/elastic/kibana/blob/main/packages/kbn-eslint-plugin-imports/README.mdx#kbnimportsno_unresolvable_imports',
},
},
create(context) {
const resolver = getImportResolver(context);
const sourceFilename = context.getPhysicalFilename
? context.getPhysicalFilename()
: context.getFilename();
if (!sourceFilename) {
throw new Error('unable to determine sourceFilename for file being linted');
}
const sourcePath = getSourcePath(context);
return visitAllImportStatements((req, { node }) => {
if (req !== null && !resolver.resolve(req, Path.dirname(sourceFilename))) {
if (req !== null && !resolver.resolve(req, Path.dirname(sourcePath))) {
report(context, {
node,
message: `Unable to resolve import [${req}]`,

View file

@ -80,7 +80,7 @@ export const NoUnusedImportsRule: Rule.RuleModule = {
meta: {
fixable: 'code',
docs: {
url: 'https://github.com/elastic/kibana/blob/main/packages/kbn-eslint-plugin-imports/README.md#kbnimportsno_unused_imports',
url: 'https://github.com/elastic/kibana/blob/main/packages/kbn-eslint-plugin-imports/README.mdx#kbnimportsno_unused_imports',
},
},
create(context) {

View file

@ -14,6 +14,7 @@ import { getRelativeImportReq, getPackageRelativeImportReq } from '@kbn/import-r
import { report } from '../helpers/report';
import { visitAllImportStatements } from '../helpers/visit_all_import_statements';
import { getSourcePath } from '../helpers/source';
import { getImportResolver } from '../get_import_resolver';
// TODO: get rid of all the special cases in here by moving more things to packages
@ -27,19 +28,15 @@ export const UniformImportsRule: Eslint.Rule.RuleModule = {
meta: {
fixable: 'code',
docs: {
url: 'https://github.com/elastic/kibana/blob/main/packages/kbn-eslint-plugin-imports/README.md#kbnimportsuniform_imports',
url: 'https://github.com/elastic/kibana/blob/main/packages/kbn-eslint-plugin-imports/README.mdx#kbnimportsuniform_imports',
},
},
create(context) {
const resolver = getImportResolver(context);
const sourceFilename = context.getPhysicalFilename
? context.getPhysicalFilename()
: context.getFilename();
const sourceDirname = Path.dirname(sourceFilename);
const ownPackageId = resolver.getPackageIdForPath(sourceFilename);
const sourcePath = getSourcePath(context);
const sourceDirname = Path.dirname(sourcePath);
const ownPackageId = resolver.getPackageIdForPath(sourcePath);
return visitAllImportStatements((req, { node, type }) => {
if (!req) {

View file

@ -0,0 +1,118 @@
load("@npm//@bazel/typescript:index.bzl", "ts_config")
load("@build_bazel_rules_nodejs//:index.bzl", "js_library")
load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project")
PKG_DIRNAME = "kbn-get-repo-files"
PKG_REQUIRE_NAME = "@kbn/get-repo-files"
SOURCE_FILES = glob(
[
"src/**/*.ts",
],
exclude = [
"**/*.test.*",
"**/*.stories.*",
],
)
SRCS = SOURCE_FILES
filegroup(
name = "srcs",
srcs = SRCS,
)
NPM_MODULE_EXTRA_FILES = [
"package.json",
]
# In this array place runtime dependencies, including other packages and NPM packages
# which must be available for this code to run.
#
# To reference other packages use:
# "//repo/relative/path/to/package"
# eg. "//packages/kbn-utils"
#
# To reference a NPM package use:
# "@npm//name-of-package"
# eg. "@npm//lodash"
RUNTIME_DEPS = [
]
# In this array place dependencies necessary to build the types, which will include the
# :npm_module_types target of other packages and packages from NPM, including @types/*
# packages.
#
# To reference the types for another package use:
# "//repo/relative/path/to/package:npm_module_types"
# eg. "//packages/kbn-utils:npm_module_types"
#
# References to NPM packages work the same as RUNTIME_DEPS
TYPES_DEPS = [
"@npm//@types/node",
"@npm//@types/jest",
"@npm//execa",
"//packages/kbn-utils:npm_module_types",
]
jsts_transpiler(
name = "target_node",
srcs = SRCS,
build_pkg_name = package_name(),
)
ts_config(
name = "tsconfig",
src = "tsconfig.json",
deps = [
"//:tsconfig.base.json",
"//:tsconfig.bazel.json",
],
)
ts_project(
name = "tsc_types",
args = ['--pretty'],
srcs = SRCS,
deps = TYPES_DEPS,
declaration = True,
declaration_map = True,
emit_declaration_only = True,
out_dir = "target_types",
root_dir = "src",
tsconfig = ":tsconfig",
)
js_library(
name = PKG_DIRNAME,
srcs = NPM_MODULE_EXTRA_FILES,
deps = RUNTIME_DEPS + [":target_node"],
package_name = PKG_REQUIRE_NAME,
visibility = ["//visibility:public"],
)
pkg_npm(
name = "npm_module",
deps = [":" + PKG_DIRNAME],
)
filegroup(
name = "build",
srcs = [":npm_module"],
visibility = ["//visibility:public"],
)
pkg_npm_types(
name = "npm_module_types",
srcs = SRCS,
deps = [":tsc_types"],
package_name = PKG_REQUIRE_NAME,
tsconfig = ":tsconfig",
visibility = ["//visibility:public"],
)
filegroup(
name = "build_types",
srcs = [":npm_module_types"],
visibility = ["//visibility:public"],
)

View file

@ -0,0 +1,10 @@
---
id: kibDevDocsOpsGetRepoFiles
slug: /kibana-dev-docs/ops/get-repo-files
title: "@kbn/get-repo-files"
description: 'A tool which lists the files under source-control'
date: 2022-07-25
tags: ['kibana', 'dev', 'contributor', 'operations', 'packages', 'scripts', 'repo', 'files']
---
This package exposes a helper to retreive a list of the files checked into the repository. It does this using the `git ls-files` CLI with some post processing to detemine if unstaged changes represent files which are modified (have edits) or deleted.

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-get-repo-files'],
};

View file

@ -0,0 +1,10 @@
{
"name": "@kbn/get-repo-files",
"private": true,
"version": "1.0.0",
"main": "./target_node/index.js",
"license": "SSPL-1.0 OR Elastic License 2.0",
"kibana": {
"devOnly": true
}
}

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import Path from 'path';
import Fs from 'fs';
import execa from 'execa';
import { REPO_ROOT } from '@kbn/utils';
interface RepoPath {
/** repo-relative path to the file */
repoRel: string;
/** absolute path to the file */
abs: string;
}
/**
* List the files in the repo, only including files which are manged by version
* control or "untracked" (new, not committed, and not ignored).
* @param include limit the list to specfic absolute paths
* @param exclude exclude specific absolute paths
*/
export async function getRepoFiles(include?: string[], exclude?: string[]) {
const flags = [
include?.map((p) => Path.relative(REPO_ROOT, p)) ?? [],
exclude?.map((p) => `--exclude=${Path.relative(REPO_ROOT, p)}`) ?? [],
].flat();
const proc = await execa('git', ['ls-files', '-comt', '--exclude-standard', ...flags], {
cwd: REPO_ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
buffer: true,
});
const paths = new Map<string, RepoPath>();
const files = new Set<RepoPath>();
for (const line of proc.stdout.split('\n').map((l) => l.trim())) {
if (!line) {
continue;
}
const repoRel = line.slice(2); // trim the single char status and separating space from the line
const existingPath = paths.get(repoRel);
const path = existingPath ?? { repoRel, abs: Path.resolve(REPO_ROOT, repoRel) };
if (!existingPath) {
paths.set(repoRel, path);
}
if (line.startsWith('C ')) {
// this line indicates that the previous path is changed in the working
// tree, so we need to determine if it was deleted and remove it if so
if (!Fs.existsSync(path.abs)) {
files.delete(path);
}
} else {
files.add(path);
}
}
return files;
}

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { getRepoFiles } from './get_repo_files';

View file

@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.bazel.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "target_types",
"rootDir": "src",
"stripInternal": false,
"types": [
"jest",
"node"
]
},
"include": [
"src/**/*"
]
}

View file

@ -0,0 +1,128 @@
load("@npm//@bazel/typescript:index.bzl", "ts_config")
load("@build_bazel_rules_nodejs//:index.bzl", "js_library")
load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project")
PKG_DIRNAME = "kbn-repo-source-classifier-cli"
PKG_REQUIRE_NAME = "@kbn/repo-source-classifier-cli"
SOURCE_FILES = glob(
[
"src/**/*.ts",
],
exclude = [
"**/*.test.*",
"**/*.stories.*",
],
)
SRCS = SOURCE_FILES
filegroup(
name = "srcs",
srcs = SRCS,
)
NPM_MODULE_EXTRA_FILES = [
"package.json",
]
# In this array place runtime dependencies, including other packages and NPM packages
# which must be available for this code to run.
#
# To reference other packages use:
# "//repo/relative/path/to/package"
# eg. "//packages/kbn-utils"
#
# To reference a NPM package use:
# "@npm//name-of-package"
# eg. "@npm//lodash"
RUNTIME_DEPS = [
"//packages/kbn-dev-cli-runner",
"//packages/kbn-dev-cli-errors",
"//packages/kbn-import-resolver",
"//packages/kbn-repo-source-classifier",
"//packages/kbn-get-repo-files",
"//packages/kbn-utils",
]
# In this array place dependencies necessary to build the types, which will include the
# :npm_module_types target of other packages and packages from NPM, including @types/*
# packages.
#
# To reference the types for another package use:
# "//repo/relative/path/to/package:npm_module_types"
# eg. "//packages/kbn-utils:npm_module_types"
#
# References to NPM packages work the same as RUNTIME_DEPS
TYPES_DEPS = [
"@npm//@types/node",
"@npm//@types/jest",
"//packages/kbn-dev-cli-runner:npm_module_types",
"//packages/kbn-dev-cli-errors:npm_module_types",
"//packages/kbn-import-resolver:npm_module_types",
"//packages/kbn-repo-source-classifier:npm_module_types",
"//packages/kbn-get-repo-files:npm_module_types",
"//packages/kbn-utils:npm_module_types",
]
jsts_transpiler(
name = "target_node",
srcs = SRCS,
build_pkg_name = package_name(),
)
ts_config(
name = "tsconfig",
src = "tsconfig.json",
deps = [
"//:tsconfig.base.json",
"//:tsconfig.bazel.json",
],
)
ts_project(
name = "tsc_types",
args = ['--pretty'],
srcs = SRCS,
deps = TYPES_DEPS,
declaration = True,
declaration_map = True,
emit_declaration_only = True,
out_dir = "target_types",
root_dir = "src",
tsconfig = ":tsconfig",
)
js_library(
name = PKG_DIRNAME,
srcs = NPM_MODULE_EXTRA_FILES,
deps = RUNTIME_DEPS + [":target_node"],
package_name = PKG_REQUIRE_NAME,
visibility = ["//visibility:public"],
)
pkg_npm(
name = "npm_module",
deps = [":" + PKG_DIRNAME],
)
filegroup(
name = "build",
srcs = [":npm_module"],
visibility = ["//visibility:public"],
)
pkg_npm_types(
name = "npm_module_types",
srcs = SRCS,
deps = [":tsc_types"],
package_name = PKG_REQUIRE_NAME,
tsconfig = ":tsconfig",
visibility = ["//visibility:public"],
)
filegroup(
name = "build_types",
srcs = [":npm_module_types"],
visibility = ["//visibility:public"],
)

View file

@ -0,0 +1,3 @@
# @kbn/repo-source-classifier-cli
CLI for debugging the repo source classifier, run to see how the classifier identifies your files. Use `node scripts/classify_source --help` for more info.

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-repo-source-classifier-cli'],
};

View file

@ -0,0 +1,10 @@
{
"name": "@kbn/repo-source-classifier-cli",
"private": true,
"version": "1.0.0",
"main": "./target_node/index.js",
"license": "SSPL-1.0 OR Elastic License 2.0",
"kibana": {
"devOnly": true
}
}

View file

@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import Path from 'path';
import { RepoSourceClassifier } from '@kbn/repo-source-classifier';
import { ImportResolver } from '@kbn/import-resolver';
import { REPO_ROOT } from '@kbn/utils';
import { getRepoFiles } from '@kbn/get-repo-files';
import { run } from '@kbn/dev-cli-runner';
import { createFlagError } from '@kbn/dev-cli-errors';
import { TypeTree } from './type_tree';
run(
async ({ flags }) => {
const resolver = ImportResolver.create(REPO_ROOT);
const classifier = new RepoSourceClassifier(resolver);
const include = flags._.length ? flags._ : [process.cwd()];
let exclude;
if (flags.exclude) {
if (Array.isArray(flags.exclude)) {
exclude = flags.exclude;
} else if (typeof flags.exclude === 'string') {
exclude = [flags.exclude];
} else {
throw createFlagError('expected --exclude value to be a string');
}
}
const typeFlags = String(flags.types)
.split(',')
.map((f) => f.trim())
.filter(Boolean);
const includeTypes: string[] = [];
const excludeTypes: string[] = [];
for (const type of typeFlags) {
if (type.startsWith('!')) {
excludeTypes.push(type.slice(1));
} else {
includeTypes.push(type);
}
}
const tree = new TypeTree();
const cwd = process.cwd();
for (const { abs } of await getRepoFiles(include, exclude)) {
const { type } = classifier.classify(abs);
if ((includeTypes.length && !includeTypes.includes(type)) || excludeTypes.includes(type)) {
continue;
}
tree.add(type, Path.relative(cwd, abs));
}
if (!!flags.flat) {
for (const file of tree.toList()) {
process.stdout.write(`${file}\n`);
}
} else {
process.stdout.write(tree.print({ expand: !!flags.expand }));
}
},
{
description: 'run the repo-source-classifier on the source files and produce a report',
usage: `node scripts/classify_source <...paths>`,
flags: {
string: ['exclude', 'types'],
boolean: ['expand', 'flat'],
help: `
<...paths> include paths to select specific files which should be reported
by default all files in the cwd are classified. Can be specified
multiple times
--exclude exclude specific paths from the classification. Can be specified
multiple times
--types limit the types reported to the types in this comma separated list
to exclude a type prefix it with !
--expand prevent collapsing entries that are of the same type
--flat just print file names
`,
},
}
);

View file

@ -0,0 +1,104 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ModuleType } from '@kbn/repo-source-classifier';
import normalizePath from 'normalize-path';
type RecursiveTypes = Map<string, ModuleType | RecursiveTypes>;
interface PrintOpts {
expand: boolean;
}
export class TypeTree {
private dirs = new Map<string, TypeTree>();
private files = new Map<string, ModuleType>();
constructor(public readonly path: string[] = []) {}
add(type: ModuleType, rel: string) {
const segs = normalizePath(rel).split('/').filter(Boolean);
let node: TypeTree = this;
const path = [];
for (const dirSeg of segs.slice(0, -1)) {
path.push(dirSeg);
const existing = node.dirs.get(dirSeg);
if (existing) {
node = existing;
} else {
const newDir = new TypeTree([...node.path, dirSeg]);
node.dirs.set(dirSeg, newDir);
node = newDir;
}
}
const filename = segs.at(-1);
if (!filename) {
throw new Error(`invalid rel path [${rel}]`);
}
node.files.set(filename, type);
}
flatten(options: PrintOpts): ModuleType | RecursiveTypes {
const entries: RecursiveTypes = new Map([
...[...this.dirs].map(([name, dir]) => [name, dir.flatten(options)] as const),
...this.files,
]);
if (!options.expand) {
const types = new Set(entries.values());
const [firstType] = types;
if (types.size === 1 && typeof firstType === 'string') {
return firstType;
}
}
return entries;
}
print(options: PrintOpts) {
const tree = this.flatten(options);
if (typeof tree === 'string') {
return `${this.path.join('/')}: ${tree}`;
}
const lines: string[] = [];
const print = (prefix: string, types: RecursiveTypes) => {
for (const [name, childTypes] of types) {
if (typeof childTypes === 'string') {
lines.push(`${prefix}${name}: ${childTypes}`);
} else {
lines.push(`${prefix}${name}/`);
print(` ${prefix}`, childTypes);
}
}
};
print('', tree);
return lines.join('\n') + '\n';
}
toList() {
const files: string[] = [];
const getFiles = (tree: TypeTree) => {
for (const dir of tree.dirs.values()) {
getFiles(dir);
}
for (const filename of tree.files.keys()) {
files.push([...tree.path, filename].join('/'));
}
};
getFiles(this);
return files.sort((a, b) => a.localeCompare(b));
}
}

View file

@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.bazel.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "target_types",
"rootDir": "src",
"stripInternal": false,
"types": [
"jest",
"node"
]
},
"include": [
"src/**/*"
]
}

View file

@ -0,0 +1,119 @@
load("@npm//@bazel/typescript:index.bzl", "ts_config")
load("@build_bazel_rules_nodejs//:index.bzl", "js_library")
load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project")
PKG_DIRNAME = "kbn-repo-source-classifier"
PKG_REQUIRE_NAME = "@kbn/repo-source-classifier"
SOURCE_FILES = glob(
[
"src/**/*.ts",
],
exclude = [
"**/*.test.*",
"**/*.stories.*",
],
)
SRCS = SOURCE_FILES
filegroup(
name = "srcs",
srcs = SRCS,
)
NPM_MODULE_EXTRA_FILES = [
"package.json",
]
# In this array place runtime dependencies, including other packages and NPM packages
# which must be available for this code to run.
#
# To reference other packages use:
# "//repo/relative/path/to/package"
# eg. "//packages/kbn-utils"
#
# To reference a NPM package use:
# "@npm//name-of-package"
# eg. "@npm//lodash"
RUNTIME_DEPS = [
]
# In this array place dependencies necessary to build the types, which will include the
# :npm_module_types target of other packages and packages from NPM, including @types/*
# packages.
#
# To reference the types for another package use:
# "//repo/relative/path/to/package:npm_module_types"
# eg. "//packages/kbn-utils:npm_module_types"
#
# References to NPM packages work the same as RUNTIME_DEPS
TYPES_DEPS = [
"@npm//@types/node",
"@npm//@types/jest",
"@npm//normalize-path",
"//packages/kbn-import-resolver:npm_module_types",
"//packages/kbn-utils:npm_module_types",
]
jsts_transpiler(
name = "target_node",
srcs = SRCS,
build_pkg_name = package_name(),
)
ts_config(
name = "tsconfig",
src = "tsconfig.json",
deps = [
"//:tsconfig.base.json",
"//:tsconfig.bazel.json",
],
)
ts_project(
name = "tsc_types",
args = ['--pretty'],
srcs = SRCS,
deps = TYPES_DEPS,
declaration = True,
declaration_map = True,
emit_declaration_only = True,
out_dir = "target_types",
root_dir = "src",
tsconfig = ":tsconfig",
)
js_library(
name = PKG_DIRNAME,
srcs = NPM_MODULE_EXTRA_FILES,
deps = RUNTIME_DEPS + [":target_node"],
package_name = PKG_REQUIRE_NAME,
visibility = ["//visibility:public"],
)
pkg_npm(
name = "npm_module",
deps = [":" + PKG_DIRNAME],
)
filegroup(
name = "build",
srcs = [":npm_module"],
visibility = ["//visibility:public"],
)
pkg_npm_types(
name = "npm_module_types",
srcs = SRCS,
deps = [":tsc_types"],
package_name = PKG_REQUIRE_NAME,
tsconfig = ":tsconfig",
visibility = ["//visibility:public"],
)
filegroup(
name = "build_types",
srcs = [":npm_module_types"],
visibility = ["//visibility:public"],
)

View file

@ -0,0 +1,31 @@
---
id: kibDevDocsOpsRepoSourceClassifier
slug: /kibana-dev-docs/ops/repo-source-classifier
title: "@kbn/repo-source-classifier"
description: 'The tool which classifies source files into categories'
date: 2022-07-25
tags: ['kibana', 'dev', 'contributor', 'operations', 'packages', 'scripts']
---
This package exposes a class which can be used to efficiently classify all of the files in the repository into one of the following groups:
- `server package`: plugin code in the root `server/` directory, eventually this will include packages of type `server-plugin` or `server-shared`
- `browser package`: plugin code in the root `public/` directory (and a few others in specific plugins), eventually this will include packages of type `browser-plugin` or `browser-shared`
- `common packages`: includes any existing package, plugin code in root `common/` directories, (and a few others in specific plugins), Eventually this will include `common-shared` packages
- `tests or mocks`: code that is loaded by jest/storybook, and mocks/helpers intended for use by that code. These files usually live along side package code but will have a separate dependency tree and are pieces of code which should never end up in the product.
- `static`: static files, currently any .json file or things loaded via `raw-loader` in browser code
- `tooling`: scripts, config files for tools like eslint, webpack, etc.
- `non-package`: code that lives outside of packages/plugins or doesn't fit into other more specific categories. Once the package project is complete this category should be limited to just `@kbn/pm`
This is a map of types to the types they are allowed to import:
- `non-package`: `non-package`, `server package`, `browser package`, `common package` or `static`
- `server package`: `common package`, `server package`, or `static`
- `browser package`: `common package`, `browser package`, or `static`
- `common package`: `common package` or`static`
- `static`: static files are not allowed to have dependencies
- `tests or mocks`: anything
- `tooling`: anything
The `RepoSourceClassifier` class implements several caches to make these lookups as efficient as possible in ESLint when all imports across the entire repository are validated. This cache lasts for the lifetime of the class and to invalidate the cache the object should just be discarded and a new instance created.
A CLI is provided for inspecting the results of the classifier, check out `node scripts/classify_source --help` for more information about usage.

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-repo-source-classifier'],
};

View file

@ -0,0 +1,10 @@
{
"name": "@kbn/repo-source-classifier",
"private": true,
"version": "1.0.0",
"main": "./target_node/index.js",
"license": "SSPL-1.0 OR Elastic License 2.0",
"kibana": {
"devOnly": true
}
}

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
// file names which indicate that the file is a testing file
export const RANDOM_TEST_FILE_NAMES = new Set([
'jest_setup',
'test_data',
'test_helper',
'test_helpers',
'stubs',
'test_utils',
'test_utilities',
'rtl_helpers',
'enzyme_helpers',
'fixtures',
'testbed',
]);
// tags are found in filenames after a `.`, like `name.tag.ts`
export const TEST_TAG = new Set([
'test',
'mock',
'mocks',
'stories',
'story',
'stub',
'fixture',
'story_decorators',
'test_helpers',
]);
// directories where test specific files are assumed to live, any file in a directory with these names is assumed to be test related
export const TEST_DIR = new Set([
'cypress',
'test',
'tests',
'testing',
'mock',
'mocks',
'__jest__',
'__mock__',
'__test__',
'__mocks__',
'__stories__',
'__fixtures__',
'__snapshots__',
'stub',
'e2e',
'ftr_e2e',
'storybook',
'.storybook',
'integration_tests',
...RANDOM_TEST_FILE_NAMES,
]);

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export type { ModuleType } from './module_type';
export { RepoSourceClassifier } from './repo_source_classifier';

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ModuleType } from './module_type';
import { PkgInfo } from './pkg_info';
export interface ModuleId {
/** Type of the module */
type: ModuleType;
/** repo relative path to the module's source file */
repoRel: string;
/** info about the package the source file is within, in the case the file is found within a package */
pkgInfo?: PkgInfo;
}

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export type ModuleType =
| 'non-package'
| 'tests or mocks'
| 'static'
| 'tooling'
| 'server package'
| 'browser package'
| 'common package';

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export interface PkgInfo {
/** id of the package this file is from */
pkgId: string;
/** Relative path to a file within a package directory */
rel: string;
/** Absolute path to the package directory */
pkgDir: string;
}

View file

@ -0,0 +1,102 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import Path from 'path';
import { REPO_ROOT } from '@kbn/utils';
import { ImportResolver } from '@kbn/import-resolver';
import normalizePath from 'normalize-path';
import { PkgInfo } from './pkg_info';
const getNormal = Path.sep === '/' ? (path: string) => path : normalizePath;
/**
* A wraper around an absolute path in the repository. We create this object and bind it
* to a specific ImportResolver primarily so that we can use it as a key in WeakMap instances
* and make analysis as efficient as possible.
*
* Instead of managing many caches in a stateful class somewhere we instead write memoized
* functions which use weakmaps that are key'd off of a RepoPath instance. RepoPath instances
* are then cached by the overall classifier, and that instance can then be cached by
* the tool running analysis on the repository
*/
export class RepoPath {
constructor(public readonly absolute: string, public readonly resolver: ImportResolver) {}
private extname: string | undefined;
/** Get the extention for this path */
getExtname() {
if (this.extname === undefined) {
this.extname = Path.extname(this.absolute);
}
return this.extname;
}
private filename: string | undefined;
/** Get the filename, without the extension, for this path */
getFilename() {
if (this.filename === undefined) {
this.filename = Path.basename(this.absolute, this.getExtname());
}
return this.filename;
}
private repoRel: string | undefined;
/** get and cache the repo-relative version of the path */
getRepoRel() {
if (this.repoRel === undefined) {
this.repoRel = getNormal(Path.relative(REPO_ROOT, this.absolute));
}
return this.repoRel;
}
private segs: string[] | undefined;
/** get and cache the path segments from the repo-realtive versions of this path */
getSegs() {
if (this.segs === undefined) {
this.segs = Path.dirname(this.getRepoRel()).split('/');
}
return this.segs;
}
private pkgInfo: PkgInfo | null | undefined;
/** get and cache the package info for a path */
getPkgInfo() {
if (this.pkgInfo === undefined) {
const pkgId = this.resolver.getPackageIdForPath(this.absolute);
if (!pkgId) {
this.pkgInfo = null;
} else {
const pkgDir = this.resolver.getAbsolutePackageDir(pkgId);
if (!pkgDir) {
throw new Error(`unable to get package directory for package [${pkgId}]`);
}
const rel = getNormal(Path.relative(pkgDir, this.absolute));
if (rel.startsWith(`../`)) {
throw new Error(
`path [${this.getRepoRel()}] does not apear to be within package [${pkgId}]`
);
}
this.pkgInfo = {
pkgDir,
pkgId,
rel,
};
}
}
return this.pkgInfo;
}
}

View file

@ -0,0 +1,192 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ImportResolver } from '@kbn/import-resolver';
import { ModuleId } from './module_id';
import { ModuleType } from './module_type';
import { RANDOM_TEST_FILE_NAMES, TEST_DIR, TEST_TAG } from './config';
import { RepoPath } from './repo_path';
export class RepoSourceClassifier {
constructor(private readonly resolver: ImportResolver) {}
private repoPaths = new Map<string, RepoPath>();
private ids = new Map<RepoPath, ModuleId>();
/**
* Get the cached repo path instance
*/
private getRepoPath(path: string) {
const cached = this.repoPaths.get(path);
if (cached !== undefined) {
return cached;
}
const rp = new RepoPath(path, this.resolver);
this.repoPaths.set(path, rp);
return rp;
}
/**
* Is this a "test" file?
*/
private isTestFile(path: RepoPath) {
const name = path.getFilename();
if (name.startsWith('mock_') || RANDOM_TEST_FILE_NAMES.has(name)) {
return true;
}
if (name.startsWith('_')) {
for (const tag of TEST_TAG) {
if (name.includes(tag)) {
return true;
}
}
}
const tag = name.split('.').at(-1);
if (tag && TEST_TAG.has(tag)) {
return true;
}
for (const seg of path.getSegs()) {
if (TEST_DIR.has(seg)) {
return true;
}
}
return false;
}
/**
* Is this a tooling file?
*/
private isToolingFile(path: RepoPath) {
const segs = path.getSegs();
if (
segs.includes('scripts') &&
!path.getRepoRel().startsWith('src/plugins/data/server/scripts/')
) {
return true;
}
if (path.getFilename() === 'webpack.config' && path.getPkgInfo()?.pkgId !== '@kbn/optimizer') {
return true;
}
return false;
}
/**
* Apply canvas specific rules
* @param root the root dir within the canvas plugin
* @param dirs the directories after the root dir
* @returns a type, or undefined if the file should be classified as a standard file
*/
private classifyCanvas(root: string, dirs: string[]): ModuleType | undefined {
if (root === 'canvas_plugin_src') {
if (dirs[0] === 'expression_types') {
return 'common package';
}
const subRoot = dirs.slice(0, 2).join('/');
if (subRoot === 'functions/external' || subRoot === 'functions/server') {
return 'server package';
}
return 'browser package';
}
if (root === 'i18n') {
return 'common package';
}
if (root === 'shareable_runtime') {
return 'non-package';
}
if (root === 'tasks') {
return 'tests or mocks';
}
}
/**
* Determine the "type" of a file
*/
private getType(path: RepoPath): ModuleType {
if (path.getExtname() === '.json') {
return 'static';
}
if (this.isTestFile(path)) {
return 'tests or mocks';
}
if (this.isToolingFile(path)) {
return 'tooling';
}
const pkgInfo = path.getPkgInfo();
if (!pkgInfo) {
return 'non-package';
}
const { pkgId, rel } = pkgInfo;
const pkgIdWords = new Set(pkgId.split(/\W+/));
// treat any package with "mocks" or "storybook" in the ID as a test-specific package
if (pkgIdWords.has('mocks') || pkgIdWords.has('storybook') || pkgIdWords.has('test')) {
return 'tests or mocks';
}
if (path.resolver.isBazelPackage(pkgId)) {
return 'common package';
}
const [root, ...dirs] = rel.split('/');
if (pkgId === '@kbn/core' && root === 'types') {
return 'common package';
}
if (pkgId === '@kbn/canvas-plugin') {
const type = this.classifyCanvas(root, dirs);
if (type) {
return type;
}
}
if (root === 'public' || root === 'static') {
return 'browser package';
}
if (root === 'server') {
return 'server package';
}
return 'common package';
}
classify(absolute: string) {
const path = this.getRepoPath(absolute);
const cached = this.ids.get(path);
if (cached) {
return cached;
}
const id: ModuleId = {
type: this.getType(path),
repoRel: path.getRepoRel(),
pkgInfo: path.getPkgInfo() ?? undefined,
};
this.ids.set(path, id);
return id;
}
}

View file

@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.bazel.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "target_types",
"rootDir": "src",
"stripInternal": false,
"types": [
"jest",
"node"
]
},
"include": [
"src/**/*"
]
}

View file

@ -87,6 +87,7 @@ TYPES_DEPS = [
"//packages/kbn-utils:npm_module_types",
"//packages/kbn-tooling-log:npm_module_types",
"//packages/kbn-bazel-packages:npm_module_types",
"//packages/kbn-get-repo-files:npm_module_types",
"@npm//@elastic/elasticsearch",
"@npm//@jest/console",
"@npm//@jest/reporters",

View file

@ -6,12 +6,10 @@
* Side Public License, v 1.
*/
import Fs from 'fs';
import Path from 'path';
import execa from 'execa';
import minimatch from 'minimatch';
import { REPO_ROOT } from '@kbn/utils';
import { getRepoFiles } from '@kbn/get-repo-files';
// @ts-expect-error jest-preset is necessarily a JS file
import { testMatch } from '../../../jest-preset';
@ -19,51 +17,31 @@ import { testMatch } from '../../../jest-preset';
const UNIT_CONFIG_NAME = 'jest.config.js';
const INTEGRATION_CONFIG_NAME = 'jest.integration.config.js';
const testsRe = (testMatch as string[]).map((p) => minimatch.makeRe(p));
const classify = (rel: string) => {
if (testsRe.some((re) => re.test(rel))) {
return 'test' as const;
}
const basename = Path.basename(rel);
return basename === UNIT_CONFIG_NAME || basename === INTEGRATION_CONFIG_NAME
? ('config' as const)
: undefined;
};
export async function getAllJestPaths() {
const proc = await execa('git', ['ls-files', '-comt', '--exclude-standard'], {
cwd: REPO_ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
buffer: true,
});
const testsRe = (testMatch as string[]).map((p) => minimatch.makeRe(p));
const classify = (rel: string) => {
if (testsRe.some((re) => re.test(rel))) {
return 'test' as const;
}
const basename = Path.basename(rel);
return basename === UNIT_CONFIG_NAME || basename === INTEGRATION_CONFIG_NAME
? ('config' as const)
: undefined;
};
const tests = new Set<string>();
const configs = new Set<string>();
for (const line of proc.stdout.split('\n').map((l) => l.trim())) {
if (!line) {
continue;
}
const rel = line.slice(2); // trim the single char status from the line
const type = classify(rel);
if (!type) {
continue;
}
const set = type === 'test' ? tests : configs;
const abs = Path.resolve(REPO_ROOT, rel);
if (line.startsWith('C ')) {
// this line indicates that the previous path is changed in the working tree, so we need to determine if
// it was deleted, and if so, remove it from the set we added it to
if (!Fs.existsSync(abs)) {
set.delete(abs);
}
} else {
set.add(abs);
for (const { repoRel, abs } of await getRepoFiles()) {
switch (classify(repoRel)) {
case 'test':
tests.add(abs);
break;
case 'config':
configs.add(abs);
break;
}
}

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
require('../src/setup_node_env/ensure_node_preserve_symlinks');
require('source-map-support/register');
require('@kbn/repo-source-classifier-cli');

View file

@ -3463,6 +3463,10 @@
version "0.0.0"
uid ""
"@kbn/get-repo-files@link:bazel-bin/packages/kbn-get-repo-files":
version "0.0.0"
uid ""
"@kbn/handlebars@link:bazel-bin/packages/kbn-handlebars":
version "0.0.0"
uid ""
@ -3559,6 +3563,14 @@
version "0.0.0"
uid ""
"@kbn/repo-source-classifier-cli@link:bazel-bin/packages/kbn-repo-source-classifier-cli":
version "0.0.0"
uid ""
"@kbn/repo-source-classifier@link:bazel-bin/packages/kbn-repo-source-classifier":
version "0.0.0"
uid ""
"@kbn/rule-data-utils@link:bazel-bin/packages/kbn-rule-data-utils":
version "0.0.0"
uid ""
@ -7191,6 +7203,10 @@
version "0.0.0"
uid ""
"@types/kbn__get-repo-files@link:bazel-bin/packages/kbn-get-repo-files/npm_module_types":
version "0.0.0"
uid ""
"@types/kbn__handlebars@link:bazel-bin/packages/kbn-handlebars/npm_module_types":
version "0.0.0"
uid ""
@ -7291,6 +7307,14 @@
version "0.0.0"
uid ""
"@types/kbn__repo-source-classifier-cli@link:bazel-bin/packages/kbn-repo-source-classifier-cli/npm_module_types":
version "0.0.0"
uid ""
"@types/kbn__repo-source-classifier@link:bazel-bin/packages/kbn-repo-source-classifier/npm_module_types":
version "0.0.0"
uid ""
"@types/kbn__rule-data-utils@link:bazel-bin/packages/kbn-rule-data-utils/npm_module_types":
version "0.0.0"
uid ""