diff --git a/common/changes/@rushstack/eslint-config/octogonz-packlets_2020-10-05-01-39.json b/common/changes/@rushstack/eslint-config/octogonz-packlets_2020-10-05-01-39.json new file mode 100644 index 00000000000..71555c74848 --- /dev/null +++ b/common/changes/@rushstack/eslint-config/octogonz-packlets_2020-10-05-01-39.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/eslint-config", + "comment": "Add a mixin to support @rushstack/eslint-plugin-packlets", + "type": "minor" + } + ], + "packageName": "@rushstack/eslint-config", + "email": "4673363+octogonz@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/eslint-plugin-packlets/octogonz-packlets_2020-10-05-01-39.json b/common/changes/@rushstack/eslint-plugin-packlets/octogonz-packlets_2020-10-05-01-39.json new file mode 100644 index 00000000000..cb7cfb94975 --- /dev/null +++ b/common/changes/@rushstack/eslint-plugin-packlets/octogonz-packlets_2020-10-05-01-39.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/eslint-plugin-packlets", + "comment": "Initial release", + "type": "minor" + } + ], + "packageName": "@rushstack/eslint-plugin-packlets", + "email": "4673363+octogonz@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/config/rush/nonbrowser-approved-packages.json b/common/config/rush/nonbrowser-approved-packages.json index e2a8a151b98..d8d80d2f3fc 100644 --- a/common/config/rush/nonbrowser-approved-packages.json +++ b/common/config/rush/nonbrowser-approved-packages.json @@ -174,6 +174,10 @@ "name": "@rushstack/eslint-plugin", "allowedCategories": [ "libraries" ] }, + { + "name": "@rushstack/eslint-plugin-packlets", + "allowedCategories": [ "libraries", "tests" ] + }, { "name": "@rushstack/eslint-plugin-security", "allowedCategories": [ "libraries" ] diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 46b2e47f411..82c477f7c0f 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -1699,6 +1699,34 @@ importers: '@typescript-eslint/typescript-estree': 3.4.0 eslint: ~7.2.0 typescript: ~3.9.7 + ../../stack/eslint-plugin-packlets: + dependencies: + '@rushstack/tree-pattern': 'link:../../libraries/tree-pattern' + devDependencies: + '@rushstack/heft': 0.14.0 + '@rushstack/heft-node-rig': 0.1.0_@rushstack+heft@0.14.0 + '@types/eslint': 7.2.0 + '@types/estree': 0.0.44 + '@types/heft-jest': 1.0.1 + '@types/node': 10.17.13 + '@typescript-eslint/experimental-utils': 3.4.0_eslint@7.2.0+typescript@3.9.7 + '@typescript-eslint/parser': 3.4.0_eslint@7.2.0+typescript@3.9.7 + '@typescript-eslint/typescript-estree': 3.4.0_typescript@3.9.7 + eslint: 7.2.0 + typescript: 3.9.7 + specifiers: + '@rushstack/heft': 0.14.0 + '@rushstack/heft-node-rig': 0.1.0 + '@rushstack/tree-pattern': 'workspace:*' + '@types/eslint': 7.2.0 + '@types/estree': 0.0.44 + '@types/heft-jest': 1.0.1 + '@types/node': 10.17.13 + '@typescript-eslint/experimental-utils': 3.4.0 + '@typescript-eslint/parser': 3.4.0 + '@typescript-eslint/typescript-estree': 3.4.0 + eslint: ~7.2.0 + typescript: ~3.9.7 ../../stack/eslint-plugin-security: dependencies: '@rushstack/tree-pattern': 'link:../../libraries/tree-pattern' @@ -2223,6 +2251,19 @@ importers: style-loader: ~1.2.1 typescript: ~3.9.7 webpack: ~4.31.0 + ../../tutorials/packlets-tutorial: + devDependencies: + '@rushstack/eslint-config': 'link:../../stack/eslint-config' + '@rushstack/heft': 'link:../../apps/heft' + '@types/node': 10.17.13 + eslint: 7.2.0 + typescript: 3.9.7 + specifiers: + '@rushstack/eslint-config': 'workspace:*' + '@rushstack/heft': 'workspace:*' + '@types/node': 10.17.13 + eslint: ~7.2.0 + typescript: ~3.9.7 ../../webpack/loader-load-themed-styles: dependencies: '@microsoft/load-themed-styles': 'link:../../libraries/load-themed-styles' diff --git a/common/config/rush/repo-state.json b/common/config/rush/repo-state.json index 83cd304ad61..e99a8707693 100644 --- a/common/config/rush/repo-state.json +++ b/common/config/rush/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "c4342cbb84a614c6f5b789815d32aca66d0e5b1b", + "pnpmShrinkwrapHash": "8a89f626be4d7746badda0720b4a1d771468e646", "preferredVersionsHash": "0f2f367d951f4cd546b698d668533c1ff056e334" } diff --git a/rush.json b/rush.json index a848b3ecb11..3ecdc8df700 100644 --- a/rush.json +++ b/rush.json @@ -923,6 +923,13 @@ "shouldPublish": true, "cyclicDependencyProjects": ["@rushstack/heft-node-rig", "@rushstack/heft"] }, + { + "packageName": "@rushstack/eslint-plugin-packlets", + "projectFolder": "stack/eslint-plugin-packlets", + "reviewCategory": "libraries", + "shouldPublish": true, + "cyclicDependencyProjects": ["@rushstack/heft-node-rig", "@rushstack/heft"] + }, { "packageName": "@rushstack/eslint-plugin-security", "projectFolder": "stack/eslint-plugin-security", @@ -1059,6 +1066,12 @@ "reviewCategory": "tests", "shouldPublish": false }, + { + "packageName": "packlets-tutorial", + "projectFolder": "tutorials/packlets-tutorial", + "reviewCategory": "tests", + "shouldPublish": false + }, // "webpack" folder (alphabetical order) { diff --git a/stack/eslint-config/README.md b/stack/eslint-config/README.md index 11b82e7d7cc..e889efba185 100644 --- a/stack/eslint-config/README.md +++ b/stack/eslint-config/README.md @@ -117,39 +117,6 @@ Optionally, you can add some "mixins" to your `extends` array to opt-in to some Important: Your **.eslintrc.js** `"extends"` field must load mixins after the profile entry. -#### `@rushstack/eslint-config/mixins/react` - -For projects using the [React](https://reactjs.org/) library, the `"@rushstack/eslint-config/mixins/react"` mixin -enables some recommended additional rules. These rules are selected via a mixin because they require you to: - -- Add `"jsx": "react"` to your **tsconfig.json** -- Configure your `settings.react.version` as shown below. This determines which React APIs will be considered - to be deprecated. (If you omit this, the React version will be detected automatically by - [loading the entire React library](https://github.com/yannickcr/eslint-plugin-react/blob/4da74518bd78f11c9c6875a159ffbae7d26be693/lib/util/version.js#L23) - into the linter's process, which is costly.) - -Add the mixin to your `"extends"` field like this: - -**.eslintrc.js** -```ts -// This is a workaround for https://github.com/eslint/eslint/issues/3458 -require('@rushstack/eslint-config/patch/modern-module-resolution'); - -module.exports = { - extends: [ - "@rushstack/eslint-config/profile/web-app", - "@rushstack/eslint-config/mixins/react" // <---- - ], - parserOptions: { tsconfigRootDir: __dirname }, - - settings: { - react: { - "version": "16.9" // <---- - } - } -}; -``` - #### `@rushstack/eslint-config/mixins/friendly-locals` Requires explicit type declarations for local variables. @@ -217,6 +184,28 @@ module.exports = { ``` +#### `@rushstack/eslint-config/mixins/packlets` + +Packlets provide a lightweight alternative to NPM packages for organizing source files within a single project. +This system is described in the [@rushstack/eslint-plugin-packlets](https://www.npmjs.com/package/@rushstack/eslint-plugin-packlets) +documentation. + +To use packlets, add the mixin to your `"extends"` field like this: + +**.eslintrc.js** +```ts +// This is a workaround for https://github.com/eslint/eslint/issues/3458 +require('@rushstack/eslint-config/patch/modern-module-resolution'); + +module.exports = { + extends: [ + "@rushstack/eslint-config/profile/node", + "@rushstack/eslint-config/profile/mixins/packlets" // <---- + ], + parserOptions: { tsconfigRootDir: __dirname } +}; +``` + #### `@rushstack/eslint-config/mixins/tsdoc` @@ -241,6 +230,41 @@ module.exports = { }; ``` + +#### `@rushstack/eslint-config/mixins/react` + +For projects using the [React](https://reactjs.org/) library, the `"@rushstack/eslint-config/mixins/react"` mixin +enables some recommended additional rules. These rules are selected via a mixin because they require you to: + +- Add `"jsx": "react"` to your **tsconfig.json** +- Configure your `settings.react.version` as shown below. This determines which React APIs will be considered + to be deprecated. (If you omit this, the React version will be detected automatically by + [loading the entire React library](https://github.com/yannickcr/eslint-plugin-react/blob/4da74518bd78f11c9c6875a159ffbae7d26be693/lib/util/version.js#L23) + into the linter's process, which is costly.) + +Add the mixin to your `"extends"` field like this: + +**.eslintrc.js** +```ts +// This is a workaround for https://github.com/eslint/eslint/issues/3458 +require('@rushstack/eslint-config/patch/modern-module-resolution'); + +module.exports = { + extends: [ + "@rushstack/eslint-config/profile/web-app", + "@rushstack/eslint-config/mixins/react" // <---- + ], + parserOptions: { tsconfigRootDir: __dirname }, + + settings: { + react: { + "version": "16.9" // <---- + } + } +}; +``` + + ## Links - [CHANGELOG.md]( diff --git a/stack/eslint-config/mixins/packlets.js b/stack/eslint-config/mixins/packlets.js new file mode 100644 index 00000000000..9c6b791e546 --- /dev/null +++ b/stack/eslint-config/mixins/packlets.js @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// This mixin implements the "packlet" formalism for organizing source files. +// For more information, see the documentation here: +// https://www.npmjs.com/package/@rushstack/eslint-plugin-packlets +module.exports = { + plugins: ['@rushstack/eslint-plugin-packlets'], + + overrides: [ + { + // Declare an override that applies to TypeScript files only + files: ['*.ts', '*.tsx'], + + rules: { + '@rushstack/packlets/mechanics': 'warn', + '@rushstack/packlets/circular-deps': 'warn' + } + } + ] +}; diff --git a/stack/eslint-config/package.json b/stack/eslint-config/package.json index 48351b45458..a2742a094b7 100644 --- a/stack/eslint-config/package.json +++ b/stack/eslint-config/package.json @@ -26,6 +26,7 @@ "dependencies": { "@rushstack/eslint-patch": "workspace:*", "@rushstack/eslint-plugin": "workspace:*", + "@rushstack/eslint-plugin-packlets": "workspace:*", "@rushstack/eslint-plugin-security": "workspace:*", "@typescript-eslint/eslint-plugin": "3.4.0", "@typescript-eslint/experimental-utils": "3.4.0", diff --git a/stack/eslint-plugin-packlets/.eslintrc.js.disabled b/stack/eslint-plugin-packlets/.eslintrc.js.disabled new file mode 100644 index 00000000000..94f24a7fc52 --- /dev/null +++ b/stack/eslint-plugin-packlets/.eslintrc.js.disabled @@ -0,0 +1,5 @@ +NOTE: We do not invoke ESLint on this project's source files, because ESLint's module resolution (as of 6.x) is naive +and fairly brittle. It gets confused between the dependencies of this project, versus the dependencies of +@rushstack/eslint-config (which imports the previously published version of this project). Normally we solve +this problem by using Rush's "cyclicDependencyProjects" feature, but that fails because ESLint does not correctly +implement NodeJS module resolution. diff --git a/stack/eslint-plugin-packlets/.npmignore b/stack/eslint-plugin-packlets/.npmignore new file mode 100644 index 00000000000..bf2eebaed77 --- /dev/null +++ b/stack/eslint-plugin-packlets/.npmignore @@ -0,0 +1,24 @@ +# Ignore everything by default +** + +# Use negative patterns to bring back the specific things we want to publish +!/bin/** +!/lib/** +!/dist/** +!ThirdPartyNotice.txt + +# Ignore certain files in the above folder +/dist/*.stats.* +/lib/**/test/* + +# NOTE: These don't need to be specified, because NPM includes them automatically. +# +# package.json +# README (and its variants) +# CHANGELOG (and its variants) +# LICENSE / LICENCE + +## Project specific definitions +# ----------------------------- + +# (Add your exceptions here) diff --git a/stack/eslint-plugin-packlets/LICENSE b/stack/eslint-plugin-packlets/LICENSE new file mode 100644 index 00000000000..579e79aa4f4 --- /dev/null +++ b/stack/eslint-plugin-packlets/LICENSE @@ -0,0 +1,24 @@ +@rushstack/eslint-plugin-packlets + +Copyright (c) Microsoft Corporation. All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/stack/eslint-plugin-packlets/README.md b/stack/eslint-plugin-packlets/README.md new file mode 100644 index 00000000000..de1dff2d60b --- /dev/null +++ b/stack/eslint-plugin-packlets/README.md @@ -0,0 +1,167 @@ +# @rushstack/eslint-plugin-packlets + +Packlets provide a lightweight alternative to NPM packages for organizing source files within a single project. The formalism is validated using ESLint rules. + +## Motivation + +When building a large application, it's a good idea to organize source files into modules, so that their dependencies can be managed. For example, suppose an application's source files can be grouped as follows: + +- `src/logging/*.ts` - the logging system +- `src/data-model/*.ts` - the data model +- `src/reports/*.ts` - the report engine +- `src/*.ts` - other arbitrary files such as startup code and the main application + +Using file folders is helpful, but it's not very strict. Files under `src/logging` can easily import files from `/src/reports`, creating a confusing circular import. They can also import arbitrary application files. Also, there is no clear distinction between which files are the "public API" for `src/logging` versus its private implementation details. + +All these problems can be solved by reorganizing the project into NPM packages (or [Rush projects](https://rushjs.io/)). Something like this: + +- `@my-app/logging` - the logging system +- `@my-app/data-model` - the data model +- `@my-app/reports` - the report engine +- `@my-app/application` - other arbitrary files such as startup code and the main application + +However, separating code in this ways has some downsides. The projects need to build separately, which has some tooling costs (for example, "watch mode" now needs to consider multiple projects). In a large monorepo, the library may attract other consumers, before the API has been fully worked out. + +Packlets provide a lightweight alternative that offers many of the same benefits of packages, but without the `package.json` file. It's a great way to prototype your project organization before later graduating your packlets into proper NPM packages. + +## 5 rules for packlets + +With packlets, our folders would be reorganized as follows: + +- `src/packlets/logging/*.ts` - the logging system +- `src/packlets/data-model/*.ts` - the data model +- `src/packlets/reports/*.ts` - the report engine +- `src/*.ts` - other arbitrary files such as startup code and the main application + +The [packlets-tutorial](https://github.com/microsoft/rushstack/tree/master/packlets/tutorials/packlets-tutorial) sample project shows this layout in more detail. + +The basic design can be summarized in 5 rules: + +1. A "packlet" is defined to be a folder path `./src/packlets//index.ts`. The **index.ts** file will have the exported APIs. The `` name must consist of lower case words separated by hyphens, similar to an NPM package name. + + Example file paths: + ``` + src/packlets/controls + src/packlets/logger + src/packlets/my-long-name + ``` + + > **NOTE:** The `packlets` cannot be nested deeper in the tree. Like with NPM packages, `src/packlets` is a flat namespace. + +2. Files outside the packlet folder can only import the packlet root **index.ts**: + + **src/app/App.ts** + ```ts + // Okay + import { MainReport } from '../packlets/reports'; + + // Error: The import statement does not use the packlet's entry point (@rushstack/packlets/mechanics) + import { MainReport } from '../packlets/reports/index'; + + // Error: The import statement does not use the packlet's entry point (@rushstack/packlets/mechanics) + import { MainReport } from '../packlets/reports/MainReport'; + ``` + +3. Files inside a packlet folder should import their siblings directly, not via their own **index.ts** (which might create a circular reference): + + **src/packlets/logging/Logger.ts** + ```ts + // Okay + import { MessageType } from "./MessageType"; + + // Error: Files under a packlet folder must not import from their own index.ts file (@rushstack/packlets/mechanics) + import { MessageType } from "."; + + // Error: Files under a packlet folder must not import from their own index.ts file (@rushstack/packlets/mechanics) + import { MessageType} from "./index"; + ``` + + +4. Packlets may reference other packlets, but not in a way that would introduce a circular dependency: + + **src/packlets/data-model/DataModel.ts** + ```ts + // Okay + import { Logger } from '../../packlets/logging'; + ``` + + **src/packlets/logging/Logger.ts** + ```ts + // Error: Packlet imports create a circular reference: (@rushstack/packlets/circular-deps) + // "logging" is referenced by src/packlets/data-model/DataModel.ts + // "data-model" is referenced by src/packlets/logging/Logger.ts + import { DataModel } from '../../packlets/data-model'; + ``` + +5. Other source files are allowed outside the **src/packlets** folder. They may import a packlet, but packlets must only import from other packlets or NPM packages. + + **src/app/App.ts** + + ```ts + // Okay + import { MainReport } from '../packlets/reports'; + ``` + + **src/packlets/data-model/ExampleModel.ts** + ```ts + // Error: A local project file cannot be imported. A packlet's dependencies must be + // NPM packages and/or other packlets. (@rushstack/packlets/mechanics) + import { App } from '../../app/App'; + ``` + + +## Getting Started + +To enable packlet validation for a simple `typescript-eslint` setup, reference the `@rushstack/eslint-plugin-packlets` project like this: + +**\/.eslintrc.js** +```js +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:@rushstack/eslint-plugin-packlets/recommended' // <--- ADD THIS + ], + parserOptions: { + project: './tsconfig.json', + sourceType: 'module', + tsconfigRootDir: __dirname + } +}; +``` + +If you use the [@rushstack/eslint-config](https://www.npmjs.com/package/@rushstack/eslint-config) ruleset, add the `"packlets"` mixin like this: + +**\/.eslintrc.js** +```ts +// This is a workaround for https://github.com/eslint/eslint/issues/3458 +require('@rushstack/eslint-config/patch/modern-module-resolution'); + +module.exports = { + extends: [ + "@rushstack/eslint-config/profile/node", + "@rushstack/eslint-config/profile/mixins/packlets" // <---- + ], + parserOptions: { tsconfigRootDir: __dirname } +}; +``` + +The `@rushstack/eslint-plugin-packlets` plugin performs validation via two separate rules: + +- `@rushstack/packlets/mechanics` - This rule validates most of the import path rules outlined above. It does not require full type information. +- `@rushstack/packlets/circular-deps` - This rule detects circular dependencies between packlets. It requires full type information from the TypeScript compiler. + + +## Links + +- [CHANGELOG.md]( + https://github.com/microsoft/rushstack/blob/master/stack/eslint-plugin-packlets/CHANGELOG.md) - Find + out what's new in the latest version +- [@rushstack/eslint-config](https://www.npmjs.com/package/@rushstack/eslint-config) documentation + +`@rushstack/eslint-plugin-packlets` is part of the [Rush Stack](https://rushstack.io/) family of projects. +The idea for packlets was originally proposed by [@bartvandenende-wm](https://github.com/bartvandenende-wm) +and [@victor-wm](https://github.com/victor-wm). diff --git a/stack/eslint-plugin-packlets/config/jest.config.json b/stack/eslint-plugin-packlets/config/jest.config.json new file mode 100644 index 00000000000..b88d4c3de66 --- /dev/null +++ b/stack/eslint-plugin-packlets/config/jest.config.json @@ -0,0 +1,3 @@ +{ + "preset": "./node_modules/@rushstack/heft/includes/jest-shared.config.json" +} diff --git a/stack/eslint-plugin-packlets/config/rig.json b/stack/eslint-plugin-packlets/config/rig.json new file mode 100644 index 00000000000..6ac88a96368 --- /dev/null +++ b/stack/eslint-plugin-packlets/config/rig.json @@ -0,0 +1,7 @@ +{ + // The "rig.json" file directs tools to look for their config files in an external package. + // Documentation for this system: https://www.npmjs.com/package/@rushstack/rig-package + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + + "rigPackageName": "@rushstack/heft-node-rig" +} diff --git a/stack/eslint-plugin-packlets/package.json b/stack/eslint-plugin-packlets/package.json new file mode 100644 index 00000000000..11263ac8cd1 --- /dev/null +++ b/stack/eslint-plugin-packlets/package.json @@ -0,0 +1,40 @@ +{ + "name": "@rushstack/eslint-plugin-packlets", + "version": "0.0.0", + "description": "A lightweight alternative to NPM packages for organizing source files within a single project", + "license": "MIT", + "repository": { + "url": "https://github.com/microsoft/rushstack/tree/master/stack/eslint-plugin-packlets" + }, + "homepage": "https://rushstack.io", + "keywords": [ + "eslint", + "eslint-config", + "packlets", + "rules" + ], + "main": "lib/index.js", + "typings": "lib/index.d.ts", + "scripts": { + "build": "heft test --clean" + }, + "dependencies": { + "@rushstack/tree-pattern": "workspace:*" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0" + }, + "devDependencies": { + "@rushstack/heft": "0.14.0", + "@rushstack/heft-node-rig": "0.1.0", + "@types/eslint": "7.2.0", + "@types/estree": "0.0.44", + "@types/heft-jest": "1.0.1", + "@types/node": "10.17.13", + "@typescript-eslint/experimental-utils": "3.4.0", + "@typescript-eslint/parser": "3.4.0", + "@typescript-eslint/typescript-estree": "3.4.0", + "eslint": "~7.2.0", + "typescript": "~3.9.7" + } +} diff --git a/stack/eslint-plugin-packlets/src/DependencyAnalyzer.ts b/stack/eslint-plugin-packlets/src/DependencyAnalyzer.ts new file mode 100644 index 00000000000..0f23fa4697d --- /dev/null +++ b/stack/eslint-plugin-packlets/src/DependencyAnalyzer.ts @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type * as ts from 'typescript'; +import * as path from 'path'; + +import { Path } from './Path'; +import { PackletAnalyzer } from './PackletAnalyzer'; + +enum RefFileKind { + Import, + ReferenceFile, + TypeReferenceDirective +} + +// TypeScript compiler internal: +// https://github.com/microsoft/TypeScript/blob/5ecdcef4cecfcdc86bd681b377636422447507d7/src/compiler/program.ts#L541 +interface RefFile { + // The absolute path of the module that was imported. + // (Normalized to an all lowercase ts.Path string.) + referencedFileName: string; + // The kind of reference. + kind: RefFileKind; + // An index indicating the order in which items occur in a compound expression + index: number; + + // The absolute path of the source file containing the import statement. + // (Normalized to an all lowercase ts.Path string.) + file: string; +} + +/** + * Represents a packlet that imports another packlet. + */ +export interface IPackletImport { + /** + * The name of the packlet being imported. + */ + packletName: string; + + /** + * The absolute path of the file that imports the packlet. + */ + fromFilePath: string; +} + +/** + * Used to build a linked list of imports that represent a circular dependency. + */ +interface IImportListNode extends IPackletImport { + /** + * The previous link in the linked list. + */ + previousNode: IImportListNode | undefined; +} + +export class DependencyAnalyzer { + /** + * @param packletName - the packlet to be checked next in our traversal + * @param startingPackletName - the packlet that we started with; if the traversal reaches this packlet, + * then a circular dependency has been detected + * @param refFileMap - the compiler's `refFileMap` data structure describing import relationships + * @param program - the compiler's `ts.Program` object + * @param packletsFolderPath - the absolute path of the "src/packlets" folder. + * @param visitedPacklets - the set of packlets that have already been visited in this traversal + * @param previousNode - a linked list of import statements that brought us to this step in the traversal + */ + private static _walkImports( + packletName: string, + startingPackletName: string, + refFileMap: Map, + program: ts.Program, + packletsFolderPath: string, + visitedPacklets: Set, + previousNode: IImportListNode | undefined + ): IImportListNode | undefined { + const packletEntryPoint: string = path.join(packletsFolderPath, packletName, 'index'); + + const tsSourceFile: ts.SourceFile | undefined = + program.getSourceFile(packletEntryPoint + '.ts') || program.getSourceFile(packletEntryPoint + '.tsx'); + if (!tsSourceFile) { + return undefined; + } + + const refFiles: RefFile[] | undefined = refFileMap.get((tsSourceFile as any).path as any); + if (!refFiles) { + return undefined; + } + + for (const refFile of refFiles) { + if (refFile.kind === RefFileKind.Import) { + const referencingFilePath: string = refFile.file; + + // Is it a reference to a packlet? + if (Path.isUnder(referencingFilePath, packletsFolderPath)) { + const referencingRelativePath: string = path.relative(packletsFolderPath, referencingFilePath); + const referencingPathParts: string[] = referencingRelativePath.split(/[\/\\]+/); + const referencingPackletName: string = referencingPathParts[0]; + + // Have we already analyzed this packlet? + if (!visitedPacklets.has(packletName)) { + visitedPacklets.add(packletName); + + // Make a new linked list node to record this step of the traversal + const importListNode: IImportListNode = { + previousNode: previousNode, + fromFilePath: referencingFilePath, + packletName: packletName + }; + + if (referencingPackletName === startingPackletName) { + // The traversal has returned to the packlet that we started from; + // this means we have detected a circular dependency + return importListNode; + } + + const result: IImportListNode | undefined = DependencyAnalyzer._walkImports( + referencingPackletName, + startingPackletName, + refFileMap, + program, + packletsFolderPath, + visitedPacklets, + importListNode + ); + if (result) { + return result; + } + } + } + } + } + + return undefined; + } + + /** + * For the specified packlet, trace all modules that import it, looking for a circular dependency + * between packlets. If found, an array is returned describing the import statements that cause + * the problem. + * + * @remarks + * For example, suppose we have files like this: + * + * ``` + * src/packlets/logging/index.ts + * src/packlets/logging/Logger.ts --> imports "../data-model" + * src/packlets/data-model/index.ts + * src/packlets/data-model/DataModel.ts --> imports "../logging" + * ``` + * + * The returned array would be: + * ```ts + * [ + * { packletName: "logging", fromFilePath: "/path/to/src/packlets/data-model/DataModel.ts" }, + * { packletName: "data-model", fromFilePath: "/path/to/src/packlets/logging/Logger.ts" }, + * ] + * ``` + * + * If there is more than one circular dependency chain, only the first one that is encountered + * will be returned. + */ + public static checkEntryPointForCircularImport( + packletName: string, + packletAnalyzer: PackletAnalyzer, + program: ts.Program + ): IPackletImport[] | undefined { + const refFileMap: Map = (program as any).getRefFileMap(); + const visitedPacklets: Set = new Set(); + + const listNode: IImportListNode | undefined = DependencyAnalyzer._walkImports( + packletName, + packletName, + refFileMap, + program, + packletAnalyzer.packletsFolderPath!, + visitedPacklets, + undefined // previousNode + ); + + if (listNode) { + // Convert the linked list to an array + const packletImports: IPackletImport[] = []; + for (let current: IImportListNode | undefined = listNode; current; current = current.previousNode) { + packletImports.push({ fromFilePath: current.fromFilePath, packletName: current.packletName }); + } + return packletImports; + } + + return undefined; + } +} diff --git a/stack/eslint-plugin-packlets/src/PackletAnalyzer.ts b/stack/eslint-plugin-packlets/src/PackletAnalyzer.ts new file mode 100644 index 00000000000..4e0c1632722 --- /dev/null +++ b/stack/eslint-plugin-packlets/src/PackletAnalyzer.ts @@ -0,0 +1,245 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'path'; +import * as fs from 'fs'; +import { Path } from './Path'; + +export type InputFileMessageIds = + | 'missing-tsconfig' + | 'missing-src-folder' + | 'packlet-folder-case' + | 'invalid-packlet-name' + | 'misplaced-packlets-folder'; + +export type ImportMessageIds = + | 'bypassed-entry-point' + | 'circular-entry-point' + | 'packlet-importing-project-file'; + +export interface IAnalyzerError { + messageId: InputFileMessageIds | ImportMessageIds; + data?: Readonly>; +} + +export class PackletAnalyzer { + private static _validPackletName: RegExp = /^[a-z0-9]+(-[a-z0-9]+)*$/; + + /** + * The input file being linted. + * + * Example: "/path/to/my-project/src/file.ts" + */ + public readonly inputFilePath: string; + + /** + * An error that occurred while analyzing the inputFilePath. + */ + public readonly error: IAnalyzerError | undefined; + + /** + * Returned to indicate that the linter can ignore this file. Possible reasons: + * - It's outside the "src" folder + * - The project doesn't define any packlets + */ + public readonly nothingToDo: boolean; + + /** + * If true, then the "src/packlets" folder exists. + */ + public readonly projectUsesPacklets: boolean; + + /** + * The absolute path of the "src/packlets" folder. + */ + public readonly packletsFolderPath: string | undefined; + + /** + * The packlet that the inputFilePath is under, if any. + */ + public readonly inputFilePackletName: string | undefined; + + /** + * Returns true if inputFilePath belongs to a packlet and is the entry point index.ts. + */ + public readonly isEntryPoint: boolean; + + private constructor(inputFilePath: string, tsconfigFilePath: string | undefined) { + this.inputFilePath = inputFilePath; + this.error = undefined; + this.nothingToDo = false; + this.projectUsesPacklets = false; + this.packletsFolderPath = undefined; + this.inputFilePackletName = undefined; + this.isEntryPoint = false; + + // Example: /path/to/my-project/src + let srcFolderPath: string | undefined; + + if (!tsconfigFilePath) { + this.error = { messageId: 'missing-tsconfig' }; + return; + } + + srcFolderPath = path.join(path.dirname(tsconfigFilePath), 'src'); + + if (!fs.existsSync(srcFolderPath)) { + this.error = { messageId: 'missing-src-folder', data: { srcFolderPath } }; + return; + } + + if (!Path.isUnder(inputFilePath, srcFolderPath)) { + // Ignore files outside the "src" folder + this.nothingToDo = true; + return; + } + + // Example: packlets/my-packlet/index.ts + const inputFilePathRelativeToSrc: string = path.relative(srcFolderPath, inputFilePath); + + // Example: [ 'packlets', 'my-packlet', 'index.ts' ] + const pathParts: string[] = inputFilePathRelativeToSrc.split(/[\/\\]+/); + + let underPackletsFolder: boolean = false; + + const expectedPackletsFolder: string = path.join(srcFolderPath, 'packlets'); + + for (let i = 0; i < pathParts.length; ++i) { + const pathPart: string = pathParts[i]; + if (pathPart.toUpperCase() === 'PACKLETS') { + if (pathPart !== 'packlets') { + // Example: /path/to/my-project/src/PACKLETS + const packletsFolderPath: string = path.join(srcFolderPath, ...pathParts.slice(0, i + 1)); + this.error = { messageId: 'packlet-folder-case', data: { packletsFolderPath } }; + return; + } + + if (i !== 0) { + this.error = { messageId: 'misplaced-packlets-folder', data: { expectedPackletsFolder } }; + return; + } + + underPackletsFolder = true; + } + } + + if (underPackletsFolder || fs.existsSync(expectedPackletsFolder)) { + // packletsAbsolutePath + this.projectUsesPacklets = true; + this.packletsFolderPath = expectedPackletsFolder; + } + + if (underPackletsFolder && pathParts.length >= 2) { + // Example: 'my-packlet' + const packletName: string = pathParts[1]; + this.inputFilePackletName = packletName; + + // Example: 'index.ts' or 'index.tsx' + const thirdPart: string = pathParts[2]; + + // Example: 'index' + const thirdPartWithoutExtension: string = path.parse(thirdPart).name; + + if (thirdPartWithoutExtension.toUpperCase() === 'INDEX') { + if (!PackletAnalyzer._validPackletName.test(packletName)) { + this.error = { messageId: 'invalid-packlet-name', data: { packletName } }; + return; + } + + this.isEntryPoint = true; + } + } + + if (this.error === undefined && !this.projectUsesPacklets) { + this.nothingToDo = true; + } + } + + public static analyzeInputFile(inputFilePath: string, tsconfigFilePath: string | undefined) { + return new PackletAnalyzer(inputFilePath, tsconfigFilePath); + } + + public analyzeImport(modulePath: string): IAnalyzerError | undefined { + if (!this.packletsFolderPath) { + // The caller should ensure this can never happen + throw new Error('Internal error: packletsFolderPath is not defined'); + } + + // Example: /path/to/my-project/src/packlets/my-packlet + const inputFileFolder: string = path.dirname(this.inputFilePath); + + // Example: /path/to/my-project/src/other-packlet/index + const importedPath: string = path.resolve(inputFileFolder, modulePath); + + // Is the imported path referring to a file under the src/packlets folder? + if (Path.isUnder(importedPath, this.packletsFolderPath)) { + // Example: other-packlet/index + const importedPathRelativeToPackletsFolder: string = path.relative( + this.packletsFolderPath, + importedPath + ); + // Example: [ 'other-packlet', 'index' ] + const importedPathParts: string[] = importedPathRelativeToPackletsFolder.split(/[\/\\]+/); + if (importedPathParts.length > 0) { + // Example: 'other-packlet' + const importedPackletName: string = importedPathParts[0]; + + // We are importing from a packlet. Is the input file part of the same packlet? + if (this.inputFilePackletName && importedPackletName === this.inputFilePackletName) { + // Yes. Then our import must NOT use the packlet entry point. + + // Example: 'index' + // + // We discard the file extension to handle a degenerate case like: + // import { X } from "../index.js"; + const lastPart: string = path.parse(importedPathParts[importedPathParts.length - 1]).name; + let pathToCompare: string; + if (lastPart.toUpperCase() === 'INDEX') { + // Example: + // importedPath = /path/to/my-project/src/other-packlet/index + // pathToCompare = /path/to/my-project/src/other-packlet + pathToCompare = path.dirname(importedPath); + } else { + pathToCompare = importedPath; + } + + // Example: /path/to/my-project/src/other-packlet + const entryPointPath: string = path.join(this.packletsFolderPath, importedPackletName); + + if (Path.isEqual(pathToCompare, entryPointPath)) { + return { + messageId: 'circular-entry-point' + }; + } + } else { + // No. If we are not part of the same packlet, then the module path must refer + // to the index.ts entry point. + + // Example: /path/to/my-project/src/other-packlet + const entryPointPath: string = path.join(this.packletsFolderPath, importedPackletName); + + if (!Path.isEqual(importedPath, entryPointPath)) { + // Example: "../packlets/other-packlet" + const entryPointModulePath: string = Path.convertToSlashes( + path.relative(inputFileFolder, entryPointPath) + ); + + return { + messageId: 'bypassed-entry-point', + data: { entryPointModulePath } + }; + } + } + } + } else { + // The imported path does NOT refer to a file under the src/packlets folder + if (this.inputFilePackletName) { + return { + messageId: 'packlet-importing-project-file' + }; + } + } + + return undefined; + } +} diff --git a/stack/eslint-plugin-packlets/src/Path.ts b/stack/eslint-plugin-packlets/src/Path.ts new file mode 100644 index 00000000000..71fe92e5cdb --- /dev/null +++ b/stack/eslint-plugin-packlets/src/Path.ts @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'path'; + +// These helpers are borrowed from @rushstack/node-core-library +export class Path { + private static _relativePathRegex: RegExp = /^[.\/\\]+$/; + + /** + * Returns true if "childPath" is located inside the "parentFolderPath" folder + * or one of its child folders. Note that "parentFolderPath" is not considered to be + * under itself. The "childPath" can refer to any type of file system object. + * + * @remarks + * The indicated file/folder objects are not required to actually exist on disk. + * For example, "parentFolderPath" is interpreted as a folder name even if it refers to a file. + * If the paths are relative, they will first be resolved using path.resolve(). + */ + public static isUnder(childPath: string, parentFolderPath: string): boolean { + const relativePath: string = path.relative(childPath, parentFolderPath); + return Path._relativePathRegex.test(relativePath); + } + + /** + * Returns true if `path1` and `path2` refer to the same underlying path. + * + * @remarks + * + * The comparison is performed using `path.relative()`. + */ + public static isEqual(path1: string, path2: string): boolean { + return path.relative(path1, path2) === ''; + } + + /** + * Replaces Windows-style backslashes with POSIX-style slashes. + * + * @remarks + * POSIX is a registered trademark of the Institute of Electrical and Electronic Engineers, Inc. + */ + public static convertToSlashes(inputPath: string): string { + return inputPath.split('\\').join('/'); + } +} diff --git a/stack/eslint-plugin-packlets/src/circular-deps.ts b/stack/eslint-plugin-packlets/src/circular-deps.ts new file mode 100644 index 00000000000..b92daf90457 --- /dev/null +++ b/stack/eslint-plugin-packlets/src/circular-deps.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type * as ts from 'typescript'; +import * as path from 'path'; + +import type { ParserServices, TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; +import { ESLintUtils } from '@typescript-eslint/experimental-utils'; + +import { PackletAnalyzer } from './PackletAnalyzer'; +import { DependencyAnalyzer, IPackletImport } from './DependencyAnalyzer'; + +export type MessageIds = 'circular-import'; +type Options = []; + +const circularDeps: TSESLint.RuleModule = { + meta: { + type: 'problem', + messages: { 'circular-import': 'Packlet imports create a circular reference:\n{{report}}' }, + schema: [ + { + type: 'object', + additionalProperties: false + } + ], + docs: { + description: 'Check for circular dependencies between packlets', + category: 'Best Practices', + recommended: 'warn', + url: 'https://www.npmjs.com/package/@rushstack/eslint-plugin-packlets' + } + }, + + create: (context: TSESLint.RuleContext) => { + // Example: /path/to/my-project/src/packlets/my-packlet/index.ts + const inputFilePath: string = context.getFilename(); + + // Example: /path/to/my-project/tsconfig.json + const program: ts.Program = ESLintUtils.getParserServices(context).program; + const tsconfigFilePath: string | undefined = program.getCompilerOptions()['configFilePath'] as string; + + const packletAnalyzer: PackletAnalyzer = PackletAnalyzer.analyzeInputFile( + inputFilePath, + tsconfigFilePath + ); + if (packletAnalyzer.nothingToDo) { + return {}; + } + + return { + // Match the first node in the source file. Ideally we should be matching "Program > :first-child" + // so a warning doesn't highlight the whole file. But that's blocked behind a bug in the query selector: + // https://github.com/estools/esquery/issues/114 + Program: (node: TSESTree.Node): void => { + if (packletAnalyzer.isEntryPoint && !packletAnalyzer.error) { + const packletImports: + | IPackletImport[] + | undefined = DependencyAnalyzer.checkEntryPointForCircularImport( + packletAnalyzer.inputFilePackletName!, + packletAnalyzer, + program + ); + + if (packletImports) { + const tsconfigFileFolder: string = path.dirname(tsconfigFilePath); + + const affectedPackletNames: string[] = packletImports.map((x) => x.packletName); + + // If 3 different packlets form a circular dependency, we don't need to report the same warning 3 times. + // Instead, only report the warning for the alphabetically smallest packlet. + affectedPackletNames.sort(); + if (affectedPackletNames[0] === packletAnalyzer.inputFilePackletName) { + let report: string = ''; + for (const packletImport of packletImports) { + const filePath: string = path.relative(tsconfigFileFolder, packletImport.fromFilePath); + report += `"${packletImport.packletName}" is referenced by ${filePath}\n`; + } + + context.report({ + node: node, + messageId: 'circular-import', + data: { report: report } + }); + } + } + } + } + }; + } +}; + +export { circularDeps }; diff --git a/stack/eslint-plugin-packlets/src/index.ts b/stack/eslint-plugin-packlets/src/index.ts new file mode 100644 index 00000000000..892bb5f8103 --- /dev/null +++ b/stack/eslint-plugin-packlets/src/index.ts @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { TSESLint } from '@typescript-eslint/experimental-utils'; +import { mechanics } from './mechanics'; +import { circularDeps } from './circular-deps'; + +interface IPlugin { + rules: { [ruleName: string]: TSESLint.RuleModule }; + configs: { [ruleName: string]: any }; +} + +const plugin: IPlugin = { + rules: { + // Full name: "@rushstack/packlets/mechanics" + mechanics: mechanics, + // Full name: "@rushstack/packlets/circular-deps" + 'circular-deps': circularDeps + }, + configs: { + recommended: { + plugins: ['@rushstack/eslint-plugin-packlets'], + rules: { + '@rushstack/packlets/mechanics': 'warn', + '@rushstack/packlets/circular-deps': 'warn' + } + } + } +}; + +export = plugin; diff --git a/stack/eslint-plugin-packlets/src/mechanics.ts b/stack/eslint-plugin-packlets/src/mechanics.ts new file mode 100644 index 00000000000..ad1a2605663 --- /dev/null +++ b/stack/eslint-plugin-packlets/src/mechanics.ts @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; +import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/experimental-utils'; + +import { PackletAnalyzer, IAnalyzerError, InputFileMessageIds, ImportMessageIds } from './PackletAnalyzer'; + +export type MessageIds = InputFileMessageIds | ImportMessageIds; +type Options = []; + +const mechanics: TSESLint.RuleModule = { + meta: { + type: 'problem', + messages: { + 'missing-tsconfig': + 'In order to use @rushstack/eslint-plugin-packlets, your ESLint config file' + + ' must configure the TypeScript parser', + 'missing-src-folder': 'Expecting to find a "src" folder at: {{srcFolderPath}}', + 'packlet-folder-case': 'The packlets folder must be all lower case: {{packletsFolderPath}}', + 'invalid-packlet-name': + 'Invalid packlet name "{{packletName}}".' + + ' The name must be lowercase alphanumeric words separated by hyphens. Example: "my-packlet"', + 'misplaced-packlets-folder': 'The packlets folder must be located at "{{expectedPackletsFolder}}"', + 'bypassed-entry-point': + 'The import statement does not use the packlet\'s entry point "{{entryPointModulePath}}"', + 'circular-entry-point': 'Files under a packlet folder must not import from their own index.ts file', + 'packlet-importing-project-file': + 'A local project file cannot be imported.' + + " A packlet's dependencies must be NPM packages and/or other packlets." + }, + schema: [ + { + type: 'object', + additionalProperties: false + } + ], + docs: { + description: 'Check that file paths and imports follow the basic mechanics for the packlet formalism', + category: 'Best Practices', + recommended: 'warn', + url: 'https://www.npmjs.com/package/@rushstack/eslint-plugin-packlets' + } + }, + + create: (context: TSESLint.RuleContext) => { + // Example: /path/to/my-project/src/packlets/my-packlet/index.ts + const inputFilePath: string = context.getFilename(); + + // Example: /path/to/my-project/tsconfig.json + const tsconfigFilePath: string | undefined = ESLintUtils.getParserServices( + context + ).program.getCompilerOptions()['configFilePath'] as string; + + const packletAnalyzer: PackletAnalyzer = PackletAnalyzer.analyzeInputFile( + inputFilePath, + tsconfigFilePath + ); + if (packletAnalyzer.nothingToDo) { + return {}; + } + + return { + // Match the first node in the source file. Ideally we should be matching "Program > :first-child" + // so a warning doesn't highlight the whole file. But that's blocked behind a bug in the query selector: + // https://github.com/estools/esquery/issues/114 + Program: (node: TSESTree.Node): void => { + if (packletAnalyzer.error) { + context.report({ + node: node, + messageId: packletAnalyzer.error.messageId, + data: packletAnalyzer.error.data + }); + } + }, + + // ImportDeclaration matches these forms: + // import { X } from '../../packlets/other-packlet'; + // import X from '../../packlets/other-packlet'; + // import type { X, Y } from '../../packlets/other-packlet'; + // import * as X from '../../packlets/other-packlet'; + // + // ExportNamedDeclaration matches these forms: + // export { X } from '../../packlets/other-packlet'; + // + // ExportAllDeclaration matches these forms: + // export * from '../../packlets/other-packlet'; + // export * as X from '../../packlets/other-packlet'; + 'ImportDeclaration, ExportNamedDeclaration, ExportAllDeclaration': ( + node: TSESTree.ImportDeclaration | TSESTree.ExportNamedDeclaration | TSESTree.ExportAllDeclaration + ): void => { + if (node.source?.type === AST_NODE_TYPES.Literal) { + if (packletAnalyzer.projectUsesPacklets) { + // Extract the import/export module path + // Example: "../../packlets/other-packlet" + const modulePath = node.source.value; + if (typeof modulePath !== 'string') { + return; + } + + if (!(modulePath.startsWith('.') || modulePath.startsWith('..'))) { + // It's not a local import. + + // Examples: + // import { X } from "npm-package"; + // import { X } from "raw-loader!./webpack-file.ts"; + return; + } + + const lint: IAnalyzerError | undefined = packletAnalyzer.analyzeImport(modulePath); + if (lint) { + context.report({ + node: node, + messageId: lint.messageId, + data: lint.data + }); + } + } + } + } + }; + } +}; + +export { mechanics }; diff --git a/stack/eslint-plugin-packlets/tsconfig.json b/stack/eslint-plugin-packlets/tsconfig.json new file mode 100644 index 00000000000..fbc2f5c0a6c --- /dev/null +++ b/stack/eslint-plugin-packlets/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./node_modules/@rushstack/heft-node-rig/profiles/default/tsconfig-base.json", + + "compilerOptions": { + "types": ["heft-jest", "node"] + } +} diff --git a/tutorials/packlets-tutorial/.eslintrc.js b/tutorials/packlets-tutorial/.eslintrc.js new file mode 100644 index 00000000000..62885de24de --- /dev/null +++ b/tutorials/packlets-tutorial/.eslintrc.js @@ -0,0 +1,7 @@ +// This is a workaround for https://github.com/eslint/eslint/issues/3458 +require('@rushstack/eslint-config/patch/modern-module-resolution'); + +module.exports = { + extends: ['@rushstack/eslint-config/profile/node', '@rushstack/eslint-config/mixins/packlets'], + parserOptions: { tsconfigRootDir: __dirname } +}; diff --git a/tutorials/packlets-tutorial/README.md b/tutorials/packlets-tutorial/README.md new file mode 100644 index 00000000000..f4083878213 --- /dev/null +++ b/tutorials/packlets-tutorial/README.md @@ -0,0 +1,5 @@ +# packlets-tutorial + +This code sample illustrates how to use "packlet" folders to organize source code within a project. + +For details, please see the [@rushstack/eslint-plugin-packlets](https://www.npmjs.com/package/@rushstack/eslint-plugin-packlets) documentation. diff --git a/tutorials/packlets-tutorial/config/heft.json b/tutorials/packlets-tutorial/config/heft.json new file mode 100644 index 00000000000..8a64ee4f00c --- /dev/null +++ b/tutorials/packlets-tutorial/config/heft.json @@ -0,0 +1,51 @@ +/** + * Defines configuration used by core Heft. + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/heft/heft.schema.json", + + "eventActions": [ + { + /** + * The kind of built-in operation that should be performed. + * The "deleteGlobs" action deletes files or folders that match the + * specified glob patterns. + */ + "actionKind": "deleteGlobs", + + /** + * The stage of the Heft run during which this action should occur. Note that actions specified in heft.json + * occur at the end of the stage of the Heft run. + */ + "heftEvent": "clean", + + /** + * A user-defined tag whose purpose is to allow configs to replace/delete handlers that were added by other + * configs. + */ + "actionId": "defaultClean", + + /** + * Glob patterns to be deleted. The paths are resolved relative to the project folder. + */ + "globsToDelete": ["dist", "lib", "temp", ".heft/build-cache/eslint.json"] + } + ], + + /** + * The list of Heft plugins to be loaded. + */ + "heftPlugins": [ + // { + // /** + // * The path to the plugin package. + // */ + // "plugin": "path/to/my-plugin", + // + // /** + // * An optional object that provides additional settings that may be defined by the plugin. + // */ + // // "options": { } + // } + ] +} diff --git a/tutorials/packlets-tutorial/package.json b/tutorials/packlets-tutorial/package.json new file mode 100644 index 00000000000..2dfd6155fe8 --- /dev/null +++ b/tutorials/packlets-tutorial/package.json @@ -0,0 +1,18 @@ +{ + "name": "packlets-tutorial", + "description": "This project illustrates how to use @rushstack/eslint-plugin-packlets", + "version": "1.0.0", + "private": true, + "license": "MIT", + "scripts": { + "build": "heft build --clean", + "start": "node lib/start.js" + }, + "devDependencies": { + "@rushstack/eslint-config": "workspace:*", + "@rushstack/heft": "workspace:*", + "@types/node": "10.17.13", + "eslint": "~7.2.0", + "typescript": "~3.9.7" + } +} diff --git a/tutorials/packlets-tutorial/src/app/App.ts b/tutorials/packlets-tutorial/src/app/App.ts new file mode 100644 index 00000000000..d1ff6599f28 --- /dev/null +++ b/tutorials/packlets-tutorial/src/app/App.ts @@ -0,0 +1,19 @@ +import { DataModel, ExampleModel } from '../packlets/data-model'; +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { Logger, MessageType } from '../packlets/logging'; +import { MainReport } from '../packlets/reports'; + +export class App { + public run(): void { + const logger: Logger = new Logger(); + logger.log(MessageType.Info, 'Starting app...'); + + const dataModel: DataModel = new ExampleModel(logger); + const report: MainReport = new MainReport(logger); + report.showReport(dataModel); + + logger.log(MessageType.Info, 'Operation completed successfully'); + } +} diff --git a/tutorials/packlets-tutorial/src/packlets/data-model/DataModel.ts b/tutorials/packlets-tutorial/src/packlets/data-model/DataModel.ts new file mode 100644 index 00000000000..56f50de2f2b --- /dev/null +++ b/tutorials/packlets-tutorial/src/packlets/data-model/DataModel.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { Logger } from '../../packlets/logging'; + +export interface IDataRecord { + firstName: string; + lastName: string; + age: number; +} + +export abstract class DataModel { + protected readonly logger: Logger; + + public constructor(logger: Logger) { + this.logger = logger; + } + public abstract queryRecords(): IDataRecord[]; +} diff --git a/tutorials/packlets-tutorial/src/packlets/data-model/ExampleModel.ts b/tutorials/packlets-tutorial/src/packlets/data-model/ExampleModel.ts new file mode 100644 index 00000000000..61ad61c935b --- /dev/null +++ b/tutorials/packlets-tutorial/src/packlets/data-model/ExampleModel.ts @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { DataModel, IDataRecord } from './DataModel'; + +export class ExampleModel extends DataModel { + public queryRecords(): IDataRecord[] { + return [ + { + firstName: 'Alice', + lastName: 'Exampleton', + age: 27 + }, + { + firstName: 'Bob', + lastName: 'Examplemeyer', + age: 31 + } + ]; + } +} diff --git a/tutorials/packlets-tutorial/src/packlets/data-model/index.ts b/tutorials/packlets-tutorial/src/packlets/data-model/index.ts new file mode 100644 index 00000000000..44c53440756 --- /dev/null +++ b/tutorials/packlets-tutorial/src/packlets/data-model/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +export * from './DataModel'; +export * from './ExampleModel'; diff --git a/tutorials/packlets-tutorial/src/packlets/logging/Logger.ts b/tutorials/packlets-tutorial/src/packlets/logging/Logger.ts new file mode 100644 index 00000000000..f7523792898 --- /dev/null +++ b/tutorials/packlets-tutorial/src/packlets/logging/Logger.ts @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { MessageType } from './MessageType'; + +export class Logger { + public log(messageType: MessageType, message: string): void { + switch (messageType) { + case MessageType.Info: + console.log('[info]: ' + message); + break; + case MessageType.Warning: + console.log('[warning]: ' + message); + break; + case MessageType.Error: + console.log('[error]: ' + message); + break; + } + } +} diff --git a/tutorials/packlets-tutorial/src/packlets/logging/MessageType.ts b/tutorials/packlets-tutorial/src/packlets/logging/MessageType.ts new file mode 100644 index 00000000000..ec7ac320a41 --- /dev/null +++ b/tutorials/packlets-tutorial/src/packlets/logging/MessageType.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +export enum MessageType { + Info, + Warning, + Error +} diff --git a/tutorials/packlets-tutorial/src/packlets/logging/index.ts b/tutorials/packlets-tutorial/src/packlets/logging/index.ts new file mode 100644 index 00000000000..a9cb7beac4d --- /dev/null +++ b/tutorials/packlets-tutorial/src/packlets/logging/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +export * from './Logger'; +export * from './MessageType'; diff --git a/tutorials/packlets-tutorial/src/packlets/reports/MainReport.ts b/tutorials/packlets-tutorial/src/packlets/reports/MainReport.ts new file mode 100644 index 00000000000..2fb96438317 --- /dev/null +++ b/tutorials/packlets-tutorial/src/packlets/reports/MainReport.ts @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { DataModel } from '../data-model'; +import { Logger, MessageType } from '../logging'; + +export class MainReport { + private readonly _logger: Logger; + + public constructor(logger: Logger) { + this._logger = logger; + this._logger.log(MessageType.Info, 'Constructing MainReport'); + } + + public showReport(dataModel: DataModel): void { + console.log('\n---------------------------------------'); + console.log('REPORT'); + console.log('---------------------------------------'); + for (const record of dataModel.queryRecords()) { + console.log(`${record.firstName} ${record.lastName}: Age=${record.age}`); + } + console.log('---------------------------------------\n'); + } +} diff --git a/tutorials/packlets-tutorial/src/packlets/reports/index.ts b/tutorials/packlets-tutorial/src/packlets/reports/index.ts new file mode 100644 index 00000000000..447a3eb6c4c --- /dev/null +++ b/tutorials/packlets-tutorial/src/packlets/reports/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +export * from './MainReport'; diff --git a/tutorials/packlets-tutorial/src/start.ts b/tutorials/packlets-tutorial/src/start.ts new file mode 100644 index 00000000000..b36a51c36a0 --- /dev/null +++ b/tutorials/packlets-tutorial/src/start.ts @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { App } from './app/App'; + +const app: App = new App(); +app.run(); diff --git a/tutorials/packlets-tutorial/tsconfig.json b/tutorials/packlets-tutorial/tsconfig.json new file mode 100644 index 00000000000..8ab12336838 --- /dev/null +++ b/tutorials/packlets-tutorial/tsconfig.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + + "compilerOptions": { + "outDir": "lib", + "rootDirs": ["src/"], + + "forceConsistentCasingInFileNames": true, + "jsx": "react", + "declaration": true, + "sourceMap": true, + "declarationMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictNullChecks": true, + "noUnusedLocals": true, + "types": ["node"], + + "module": "commonjs", + "target": "es2017", + "lib": ["es2017"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "lib"] +}