diff --git a/.gitignore b/.gitignore index 785cfadf..955f8f26 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules *.log .eslintcache +transforms/**/*.js +!transforms/ember-object/__testfixtures__/**/*.js diff --git a/README.md b/README.md index ea46fbbd..c4b11c9a 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,14 @@ If you have any _lazily loaded_ modules, such as modules from Ember Engines, you'll need to make sure that the URL you provide loads these modules as well. Otherwise, the codemod will not be able to detect them or analyze them. +To disable this feature, run with `NO_TELEMETRY=true` and omit the path to your local server: + +```shell +NO_TELEMETRY=true npx ember-native-class-codemod [OPTIONS] path/of/files/ or/some**/*glob.js +``` + +DANGER: Disabling the telemetry may result in incorrect behavior. Carefully vet the results. + ### Types The `type` option can be used to further narrow down transforms to a particular type of diff --git a/bin/cli.js b/bin/cli.js index 94074b6a..f4172d84 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -4,11 +4,11 @@ const { gatherTelemetryForUrl, analyzeEmberObject } = require('ember-codemods-telemetry-helpers'); (async () => { - await gatherTelemetryForUrl(process.argv[2], analyzeEmberObject); + let args = process.argv.slice(1); + if (process.env['NO_TELEMETRY'] !== 'true') { + await gatherTelemetryForUrl(process.argv[2], analyzeEmberObject); + args = process.argv.slice(2); + } - require('codemod-cli').runTransform( - __dirname, - 'ember-object', - process.argv.slice(2) /* paths or globs */ - ); + require('codemod-cli').runTransform(__dirname, 'ember-object', args); })(); diff --git a/transforms/ember-object/__testfixtures__/-mock-telemetry.json b/transforms/ember-object/__testfixtures__/-mock-telemetry.json index f6027d81..00480f54 100644 --- a/transforms/ember-object/__testfixtures__/-mock-telemetry.json +++ b/transforms/ember-object/__testfixtures__/-mock-telemetry.json @@ -1,8 +1,8 @@ { "runtime": { "computedProperties": ["computedMacro", "anotherMacro", "numPlusOne", "numPlusPlus", "error", "errorService"], - "observedProperties": [], - "observerProperties": {}, + "observedProperties": ["prop"], + "observerProperties": { "observerProp": ["prop"] }, "offProperties": { "offProp": ["prop1", "prop2"] }, "overriddenActions": ["overriddenActionMethod"], "overriddenProperties": ["overriddenMethod"], diff --git a/transforms/ember-object/__testfixtures__/decorators-invalid-2.options.json b/transforms/ember-object/__testfixtures__/decorators-invalid-2.options.json new file mode 100644 index 00000000..0709fcc3 --- /dev/null +++ b/transforms/ember-object/__testfixtures__/decorators-invalid-2.options.json @@ -0,0 +1,3 @@ +{ + "noTelemetry": "false" +} \ No newline at end of file diff --git a/transforms/ember-object/__testfixtures__/decorators-invalid-4.options.json b/transforms/ember-object/__testfixtures__/decorators-invalid-4.options.json new file mode 100644 index 00000000..0709fcc3 --- /dev/null +++ b/transforms/ember-object/__testfixtures__/decorators-invalid-4.options.json @@ -0,0 +1,3 @@ +{ + "noTelemetry": "false" +} \ No newline at end of file diff --git a/transforms/ember-object/__testfixtures__/object-literal-with-decorators-invalid-1.options.json b/transforms/ember-object/__testfixtures__/object-literal-with-decorators-invalid-1.options.json new file mode 100644 index 00000000..31096406 --- /dev/null +++ b/transforms/ember-object/__testfixtures__/object-literal-with-decorators-invalid-1.options.json @@ -0,0 +1,3 @@ +{ + "noTelemetry": false +} \ No newline at end of file diff --git a/transforms/ember-object/__testfixtures__/object-literal-with-decorators-invalid-2.options.json b/transforms/ember-object/__testfixtures__/object-literal-with-decorators-invalid-2.options.json new file mode 100644 index 00000000..31096406 --- /dev/null +++ b/transforms/ember-object/__testfixtures__/object-literal-with-decorators-invalid-2.options.json @@ -0,0 +1,3 @@ +{ + "noTelemetry": false +} \ No newline at end of file diff --git a/transforms/ember-object/__testfixtures__/object-literal-with-decorators-invalid-3.options.json b/transforms/ember-object/__testfixtures__/object-literal-with-decorators-invalid-3.options.json new file mode 100644 index 00000000..31096406 --- /dev/null +++ b/transforms/ember-object/__testfixtures__/object-literal-with-decorators-invalid-3.options.json @@ -0,0 +1,3 @@ +{ + "noTelemetry": false +} \ No newline at end of file diff --git a/transforms/ember-object/__testfixtures__/object-literal-with-decorators-invalid-4.options.json b/transforms/ember-object/__testfixtures__/object-literal-with-decorators-invalid-4.options.json new file mode 100644 index 00000000..31096406 --- /dev/null +++ b/transforms/ember-object/__testfixtures__/object-literal-with-decorators-invalid-4.options.json @@ -0,0 +1,3 @@ +{ + "noTelemetry": false +} \ No newline at end of file diff --git a/transforms/ember-object/__testfixtures__/options/decorators-disabled-1.options.json b/transforms/ember-object/__testfixtures__/options/decorators-disabled-1.options.json index c3e65ac9..440b9d8a 100644 --- a/transforms/ember-object/__testfixtures__/options/decorators-disabled-1.options.json +++ b/transforms/ember-object/__testfixtures__/options/decorators-disabled-1.options.json @@ -1,3 +1,4 @@ { - "decorators": false + "decorators": false, + "noTelemetry": false } diff --git a/transforms/ember-object/__testfixtures__/options/decorators-disabled-10.options.json b/transforms/ember-object/__testfixtures__/options/decorators-disabled-10.options.json index c3e65ac9..440b9d8a 100644 --- a/transforms/ember-object/__testfixtures__/options/decorators-disabled-10.options.json +++ b/transforms/ember-object/__testfixtures__/options/decorators-disabled-10.options.json @@ -1,3 +1,4 @@ { - "decorators": false + "decorators": false, + "noTelemetry": false } diff --git a/transforms/ember-object/__testfixtures__/options/decorators-disabled-11.options.json b/transforms/ember-object/__testfixtures__/options/decorators-disabled-11.options.json index c3e65ac9..440b9d8a 100644 --- a/transforms/ember-object/__testfixtures__/options/decorators-disabled-11.options.json +++ b/transforms/ember-object/__testfixtures__/options/decorators-disabled-11.options.json @@ -1,3 +1,4 @@ { - "decorators": false + "decorators": false, + "noTelemetry": false } diff --git a/transforms/ember-object/__testfixtures__/options/decorators-disabled-12.options.json b/transforms/ember-object/__testfixtures__/options/decorators-disabled-12.options.json index f8341e9c..d8584230 100644 --- a/transforms/ember-object/__testfixtures__/options/decorators-disabled-12.options.json +++ b/transforms/ember-object/__testfixtures__/options/decorators-disabled-12.options.json @@ -1,4 +1,5 @@ { "decorators": false, - "inObjectLiterals": ["macro"] + "inObjectLiterals": ["macro"], + "noTelemetry": false } diff --git a/transforms/ember-object/__testfixtures__/options/decorators-disabled-13.options.json b/transforms/ember-object/__testfixtures__/options/decorators-disabled-13.options.json index c3e65ac9..440b9d8a 100644 --- a/transforms/ember-object/__testfixtures__/options/decorators-disabled-13.options.json +++ b/transforms/ember-object/__testfixtures__/options/decorators-disabled-13.options.json @@ -1,3 +1,4 @@ { - "decorators": false + "decorators": false, + "noTelemetry": false } diff --git a/transforms/ember-object/__testfixtures__/options/decorators-disabled-2.options.json b/transforms/ember-object/__testfixtures__/options/decorators-disabled-2.options.json index c3e65ac9..440b9d8a 100644 --- a/transforms/ember-object/__testfixtures__/options/decorators-disabled-2.options.json +++ b/transforms/ember-object/__testfixtures__/options/decorators-disabled-2.options.json @@ -1,3 +1,4 @@ { - "decorators": false + "decorators": false, + "noTelemetry": false } diff --git a/transforms/ember-object/__testfixtures__/options/decorators-disabled-3.options.json b/transforms/ember-object/__testfixtures__/options/decorators-disabled-3.options.json index c3e65ac9..440b9d8a 100644 --- a/transforms/ember-object/__testfixtures__/options/decorators-disabled-3.options.json +++ b/transforms/ember-object/__testfixtures__/options/decorators-disabled-3.options.json @@ -1,3 +1,4 @@ { - "decorators": false + "decorators": false, + "noTelemetry": false } diff --git a/transforms/ember-object/__testfixtures__/options/decorators-disabled-4.options.json b/transforms/ember-object/__testfixtures__/options/decorators-disabled-4.options.json index c3e65ac9..440b9d8a 100644 --- a/transforms/ember-object/__testfixtures__/options/decorators-disabled-4.options.json +++ b/transforms/ember-object/__testfixtures__/options/decorators-disabled-4.options.json @@ -1,3 +1,4 @@ { - "decorators": false + "decorators": false, + "noTelemetry": false } diff --git a/transforms/ember-object/__testfixtures__/options/decorators-disabled-5.options.json b/transforms/ember-object/__testfixtures__/options/decorators-disabled-5.options.json index c3e65ac9..440b9d8a 100644 --- a/transforms/ember-object/__testfixtures__/options/decorators-disabled-5.options.json +++ b/transforms/ember-object/__testfixtures__/options/decorators-disabled-5.options.json @@ -1,3 +1,4 @@ { - "decorators": false + "decorators": false, + "noTelemetry": false } diff --git a/transforms/ember-object/__testfixtures__/options/decorators-disabled-6.options.json b/transforms/ember-object/__testfixtures__/options/decorators-disabled-6.options.json index c3e65ac9..440b9d8a 100644 --- a/transforms/ember-object/__testfixtures__/options/decorators-disabled-6.options.json +++ b/transforms/ember-object/__testfixtures__/options/decorators-disabled-6.options.json @@ -1,3 +1,4 @@ { - "decorators": false + "decorators": false, + "noTelemetry": false } diff --git a/transforms/ember-object/__testfixtures__/options/decorators-disabled-7.options.json b/transforms/ember-object/__testfixtures__/options/decorators-disabled-7.options.json index c3e65ac9..440b9d8a 100644 --- a/transforms/ember-object/__testfixtures__/options/decorators-disabled-7.options.json +++ b/transforms/ember-object/__testfixtures__/options/decorators-disabled-7.options.json @@ -1,3 +1,4 @@ { - "decorators": false + "decorators": false, + "noTelemetry": false } diff --git a/transforms/ember-object/__testfixtures__/options/decorators-disabled-8.options.json b/transforms/ember-object/__testfixtures__/options/decorators-disabled-8.options.json index c3e65ac9..440b9d8a 100644 --- a/transforms/ember-object/__testfixtures__/options/decorators-disabled-8.options.json +++ b/transforms/ember-object/__testfixtures__/options/decorators-disabled-8.options.json @@ -1,3 +1,4 @@ { - "decorators": false + "decorators": false, + "noTelemetry": false } diff --git a/transforms/ember-object/__testfixtures__/options/decorators-disabled-9.options.json b/transforms/ember-object/__testfixtures__/options/decorators-disabled-9.options.json index c3e65ac9..440b9d8a 100644 --- a/transforms/ember-object/__testfixtures__/options/decorators-disabled-9.options.json +++ b/transforms/ember-object/__testfixtures__/options/decorators-disabled-9.options.json @@ -1,3 +1,4 @@ { - "decorators": false + "decorators": false, + "noTelemetry": false } diff --git a/transforms/ember-object/__testfixtures__/runtime.input.js b/transforms/ember-object/__testfixtures__/runtime.input.js index 3c8029fc..8d1c8c75 100644 --- a/transforms/ember-object/__testfixtures__/runtime.input.js +++ b/transforms/ember-object/__testfixtures__/runtime.input.js @@ -1,7 +1,8 @@ -import Runtime from 'common/runtime'; +import { computed, observer } from '@ember/object'; import { alias } from '@ember/object/computed'; -import { computed } from '@ember/object'; import { service } from '@ember/service'; +import Runtime from 'common/runtime'; +import { customMacro, customMacroWithInput } from 'my-app/lib'; /** * Program comments @@ -19,6 +20,8 @@ export default Runtime.extend(MyMixin, { error: service(), errorService: service('error'), + observerProp: observer('prop', function() { return this.prop; }), + unobservedProp: null, offProp: null, diff --git a/transforms/ember-object/__testfixtures__/runtime.options.json b/transforms/ember-object/__testfixtures__/runtime.options.json new file mode 100644 index 00000000..0709fcc3 --- /dev/null +++ b/transforms/ember-object/__testfixtures__/runtime.options.json @@ -0,0 +1,3 @@ +{ + "noTelemetry": "false" +} \ No newline at end of file diff --git a/transforms/ember-object/__testfixtures__/runtime.output.js b/transforms/ember-object/__testfixtures__/runtime.output.js index 7eec4425..1489e55a 100644 --- a/transforms/ember-object/__testfixtures__/runtime.output.js +++ b/transforms/ember-object/__testfixtures__/runtime.output.js @@ -1,9 +1,10 @@ import classic from 'ember-classic-decorator'; -import { off, unobserves } from '@ember-decorators/object'; +import { off, unobserves, observes } from '@ember-decorators/object'; import { action, computed } from '@ember/object'; import { service } from '@ember/service'; import { alias } from '@ember/object/computed'; import Runtime from 'common/runtime'; +import { customMacro, customMacroWithInput } from 'my-app/lib'; /** * Program comments @@ -26,6 +27,9 @@ export default class _Runtime extends Runtime.extend(MyMixin) { @service('error') errorService; + @observes('prop') + observerProp() { return this.prop; } + @unobserves('prop3', 'prop4') unobservedProp; diff --git a/transforms/ember-object/test.ts b/transforms/ember-object/test.ts index bb284e67..fdd966f7 100644 --- a/transforms/ember-object/test.ts +++ b/transforms/ember-object/test.ts @@ -11,6 +11,9 @@ import mockTelemetryData from './__testfixtures__/-mock-telemetry.json'; const fixtureDir = 'transforms/ember-object/__testfixtures__/'; const testFiles = new GlobSync(`${fixtureDir}**/*.input.js`).found; +const OUTPUT_PARSER = + /^(?:\/\*\nExpect error:\n(?[\S\s]*)\n\*\/)?(?[\S\s]*)$/; + const mockTelemetry: Record = {}; for (const testFile of testFiles) { const moduleName = testFile @@ -31,7 +34,9 @@ interface TestCase { testPath: string; input: string; output: string; + outputWithNoTelemetry: string; expectedError: string | undefined; + expectedErrorWithNoTelemetry: string | undefined; skipped: boolean; options: string; } @@ -44,18 +49,27 @@ const testCases = testFiles.map((inputPath): TestCase => { const testPath = path.join(fixtureDir, `${testName}${extension}`); const input = readFileSync(inputPath, 'utf8'); const outputPath = path.join(fixtureDir, `${testName}.output${extension}`); + const outputWithNoTelemetryPath = path.join( + fixtureDir, + `${testName}.output-no-telemetry${extension}` + ); - const fullOutput = readFileSync(outputPath, 'utf8'); - const parsedOutput = - /^(?:\/\*\nExpect error:\n(?[\S\s]*)\n\*\/)?(?[\S\s]*)$/.exec( - fullOutput - ); - const output = parsedOutput?.groups?.['content']; - assert(output, `Expected to find file content in ${outputPath}`); - const expectedError = parsedOutput.groups?.['expectedError']; + const { fullOutput, output, expectedError } = parseOutput(outputPath); const skipped = fullOutput.startsWith('/* Expect skipped */'); + let outputWithNoTelemetry; + let expectedErrorWithNoTelemetry; + if (existsSync(outputWithNoTelemetryPath)) { + ({ + output: outputWithNoTelemetry, + expectedError: expectedErrorWithNoTelemetry, + } = parseOutput(outputWithNoTelemetryPath)); + } else { + outputWithNoTelemetry = output; + expectedErrorWithNoTelemetry = expectedError; + } + const optionsPath = path.join(fixtureDir, `${testName}.options.json`); const options = existsSync(optionsPath) ? readFileSync(optionsPath, 'utf8') @@ -65,7 +79,9 @@ const testCases = testFiles.map((inputPath): TestCase => { testPath, input, output, + outputWithNoTelemetry, expectedError, + expectedErrorWithNoTelemetry, skipped, options, }; @@ -74,7 +90,20 @@ const testCases = testFiles.map((inputPath): TestCase => { describe('ember-object', () => { describe.each(testCases)( '$testName', - ({ testPath, input, output, expectedError, skipped, options }) => { + ({ + testPath, + input, + output, + outputWithNoTelemetry, + expectedError, + expectedErrorWithNoTelemetry, + skipped, + options, + }) => { + const parsedOptions = options + ? (JSON.parse(options) as Record) + : {}; + beforeEach(function () { process.env['CODEMOD_CLI_ARGS'] = options; }); @@ -90,10 +119,48 @@ describe('ember-object', () => { test('is idempotent', function () { runTest(testPath, output, output, expectedError, skipped); }); + + // NOTE: To skip testing an input file in no telemetry file, make an + // options file with `{ "noTelemetry": "false" }` + if (parsedOptions['noTelemetry'] === undefined) { + describe('NO_TELEMETRY', function () { + beforeEach(function () { + process.env['CODEMOD_CLI_ARGS'] = JSON.stringify({ + noTelemetry: true, + ...parsedOptions, + }); + }); + + test('transforms correctly', function () { + runTest( + testPath, + input, + outputWithNoTelemetry, + expectedErrorWithNoTelemetry, + skipped + ); + }); + + // FIXME: is idempotent + }); + } } ); }); +function parseOutput(outputPath: string): { + fullOutput: string; + output: string; + expectedError: string | undefined; +} { + const fullOutput = readFileSync(outputPath, 'utf8'); + const parsedOutput = OUTPUT_PARSER.exec(fullOutput); + const output = parsedOutput?.groups?.['content']; + assert(output, `Expected to find file content in ${outputPath}`); + const expectedError = parsedOutput.groups?.['expectedError']; + return { fullOutput, output, expectedError }; +} + function runTest( testPath: string, input: string, diff --git a/transforms/helpers/config.ts b/transforms/helpers/config.ts index d20e14e9..22c3da4c 100644 --- a/transforms/helpers/config.ts +++ b/transforms/helpers/config.ts @@ -22,6 +22,9 @@ export default function getConfig(dir = process.cwd()): UserOptions { getFileConfig(dir), getCliConfig() ); + if (process.env['NO_TELEMETRY'] === 'true') { + config.noTelemetry = true; + } return UserOptionsSchema.parse(config); } diff --git a/transforms/helpers/decorator-info.ts b/transforms/helpers/decorator-info.ts index 637693f6..c7fbb8f5 100644 --- a/transforms/helpers/decorator-info.ts +++ b/transforms/helpers/decorator-info.ts @@ -1,6 +1,6 @@ -import { assert } from './util/types'; -import { COMPUTED_DECORATOR_NAME, METHOD_DECORATORS } from './util/index'; import type * as AST from '../helpers/ast'; +import { COMPUTED_DECORATOR_NAME, METHOD_DECORATORS } from './util/index'; +import { assert } from './util/types'; export interface DecoratorImportInfo { name: string; diff --git a/transforms/helpers/eo-extend-expression.ts b/transforms/helpers/eo-extend-expression.ts index 06a12361..6b18648f 100644 --- a/transforms/helpers/eo-extend-expression.ts +++ b/transforms/helpers/eo-extend-expression.ts @@ -31,8 +31,13 @@ export default class EOExtendExpression { ) { const raw = path.value; - this.className = getClassName(path, filePath, options.runtimeData.type); this.superClassName = raw.callee.object.name; + this.className = getClassName( + path, + filePath, + this.superClassName, + options.runtimeData?.type + ); const mixins: AST.EOMixin[] = []; for (const arg of raw.arguments) { @@ -92,6 +97,7 @@ export default class EOExtendExpression { templateLayout: false, off: false, tagName: false, + observes: false, unobserves: false, }; const { properties } = this; @@ -110,6 +116,7 @@ export default class EOExtendExpression { specs.templateLayout || prop.decoratorImportSpecs.templateLayout, off: specs.off || prop.decoratorImportSpecs.off, tagName: specs.tagName || prop.decoratorImportSpecs.tagName, + observes: specs.observes || prop.decoratorImportSpecs.observes, unobserves: specs.unobserves || prop.decoratorImportSpecs.unobserves, }; } diff --git a/transforms/helpers/eo-prop/private/abstract.ts b/transforms/helpers/eo-prop/private/abstract.ts index 2fc3237a..18a82dd5 100644 --- a/transforms/helpers/eo-prop/private/abstract.ts +++ b/transforms/helpers/eo-prop/private/abstract.ts @@ -4,6 +4,7 @@ import type { Options } from '../../options'; import type { RuntimeData } from '../../runtime-data'; import type { DecoratorImportSpecs } from '../../util/index'; import { + OBSERVES_DECORATOR_NAME, OFF_DECORATOR_NAME, UNOBSERVES_DECORATOR_NAME, allowObjectLiteralDecorator, @@ -36,7 +37,7 @@ export default abstract class AbstractEOProp< protected decorators: DecoratorImportInfo[] = []; - protected readonly runtimeData: RuntimeData; + protected readonly runtimeData: RuntimeData | null; /** * Override to `true` if the property type supports object literal decorators @@ -55,7 +56,7 @@ export default abstract class AbstractEOProp< protected readonly options: Options ) { this.runtimeData = options.runtimeData; - if (this.runtimeData.type) { + if (this.runtimeData) { const { offProperties, unobservedProperties } = this.runtimeData; const unobservedArgs = unobservedProperties[this.name]; @@ -91,6 +92,7 @@ export default abstract class AbstractEOProp< templateLayout: false, off: this.hasOffDecorator, tagName: false, + observes: this.hasObservesDecorator, unobserves: this.hasUnobservesDecorator, }; } @@ -163,7 +165,7 @@ export default abstract class AbstractEOProp< } protected get isOverridden(): boolean { - return this.runtimeData.overriddenProperties.includes(this.name); + return this.runtimeData?.overriddenProperties.includes(this.name) ?? false; } protected get replaceSuperWithUndefined(): boolean { @@ -171,7 +173,7 @@ export default abstract class AbstractEOProp< } private get hasRuntimeData(): boolean { - return !!this.runtimeData.type; + return this.runtimeData !== null; } protected get hasDecorators(): boolean { @@ -182,6 +184,10 @@ export default abstract class AbstractEOProp< return this.hasExistingDecorators || this.hasDecorators; } + private get hasObservesDecorator(): boolean { + return this.decorators.some((d) => d.name === OBSERVES_DECORATOR_NAME); + } + private get hasUnobservesDecorator(): boolean { return this.decorators.some((d) => d.name === UNOBSERVES_DECORATOR_NAME); } diff --git a/transforms/helpers/eo-prop/private/actions/method.ts b/transforms/helpers/eo-prop/private/actions/method.ts index 1e850330..7f97f8e3 100644 --- a/transforms/helpers/eo-prop/private/actions/method.ts +++ b/transforms/helpers/eo-prop/private/actions/method.ts @@ -87,6 +87,6 @@ export default class EOActionMethod extends EOMethod implements Action { } protected override get isOverridden(): boolean { - return this.runtimeData.overriddenActions.includes(this.name); + return this.runtimeData?.overriddenActions.includes(this.name) ?? false; } } diff --git a/transforms/helpers/eo-prop/private/computed/abstract.ts b/transforms/helpers/eo-prop/private/computed/abstract.ts index f7edd9de..9626f914 100644 --- a/transforms/helpers/eo-prop/private/computed/abstract.ts +++ b/transforms/helpers/eo-prop/private/computed/abstract.ts @@ -3,10 +3,10 @@ import type * as AST from '../../../ast'; import type { DecoratorImportInfo } from '../../../decorator-info'; import logger from '../../../log-helper'; import type { Options } from '../../../options'; +import { COMPUTED_DECORATOR_NAME } from '../../../util/index'; import { defined } from '../../../util/types'; import AbstractEOProp from '../abstract'; import type { CallExpressionModifier } from './modifier-helper'; -import { COMPUTED_DECORATOR_NAME } from '../../../util/index'; /** * Ember Object Call Expression Property diff --git a/transforms/helpers/eo-prop/private/computed/index.ts b/transforms/helpers/eo-prop/private/computed/index.ts index 562c4edc..2a4b6640 100644 --- a/transforms/helpers/eo-prop/private/computed/index.ts +++ b/transforms/helpers/eo-prop/private/computed/index.ts @@ -99,7 +99,10 @@ function getDecorators( const decoratorImportInfo = existingDecoratorImportInfos.get(calleeName); if (decoratorImportInfo) { decorators.push(decoratorImportInfo); - } else if (options.runtimeData.computedProperties.includes(raw.key.name)) { + } else if ( + options.noTelemetry || + options.runtimeData?.computedProperties.includes(raw.key.name) + ) { decorators.push({ name: calleeName }); } return decorators; diff --git a/transforms/helpers/import-helper.ts b/transforms/helpers/import-helper.ts index dc62696e..37e34d3f 100644 --- a/transforms/helpers/import-helper.ts +++ b/transforms/helpers/import-helper.ts @@ -7,11 +7,7 @@ import { createEmberDecoratorSpecifiers, createImportDeclaration, } from './transform-helper'; -import { - DECORATOR_PATHS, - DECORATOR_PATH_OVERRIDES, - EMBER_DECORATOR_SPECIFIERS, -} from './util/index'; +import { EMBER_DECORATOR_SPECIFIERS, PROPS_TO_DECORATORS } from './util/index'; import { assert, defined, isString, verified } from './util/types'; /** Returns true of the specifier is a decorator */ @@ -66,7 +62,7 @@ function getExistingDecoratorImports( ): Array> { const imports: Array> = []; - for (const path in Object.fromEntries(DECORATOR_PATHS)) { + for (const path in Object.fromEntries(PROPS_TO_DECORATORS)) { const decoratorImport = getExistingImportForPath(root, path); if (decoratorImport) { imports.push(decoratorImport); @@ -143,71 +139,62 @@ function getDecoratorPathSpecifiers( // Extract and process the specifiers // Construct the map with path as key and value as list of specifiers to import from the path for (const decoratorImport of existingDecoratorImports) { - const { importPropDecoratorMap, decoratorPath } = defined( - DECORATOR_PATHS.get( - verified(decoratorImport.value.source.value, isString) - ) - ); - // Decorators to be imported for the path - // These are typically additional decorators which need to be imported for a path - // For example - `@action` decorator - const decoratorsForPath = edPathNameMap.get(decoratorPath) ?? []; - // delete the visited path to avoid duplicate imports - edPathNameMap.delete(decoratorPath); - - // Create decorator specifiers for which no existing specifiers present in the current path - // e.g. `actions` need not to be imported but `@action` need to be imported from `@ember-decorators/object` - const decoratedSpecifiers = createEmberDecoratorSpecifiers( - decoratorsForPath, - decoratorsToImport - ); - const existingSpecifiers = decoratorImport.value.specifiers ?? []; - - // Iterate over existing specifiers for the current path. This is needed - // to pick the only required specifiers from the existing imports - // For example - To pick `observer` from `import { get, set, observer } from "@ember/object"` - for (let i = existingSpecifiers.length - 1; i >= 0; i -= 1) { - const existingSpecifier = defined(existingSpecifiers[i]); - - if (isSpecifierDecorator(existingSpecifier, importPropDecoratorMap)) { - // Update decorator local and imported names, - // Needed in case of `observer` which need to be renamed to `@observes` - setSpecifierNames(existingSpecifier, importPropDecoratorMap); - // Check if the decorator import path is overridden - // Needed in case of `observes` which need to be imported from `@ember-decorators/object` - const overriddenPath = DECORATOR_PATH_OVERRIDES.get( - existingSpecifier.imported.name - ); - if (overriddenPath) { - decoratorPathSpecifierMap[overriddenPath] = [ - ...(decoratorPathSpecifierMap[overriddenPath] ?? []), - existingSpecifier, - ]; - } else { - const isSpecifierPresent = decoratedSpecifiers.some((specifier) => { + const path = verified(decoratorImport.value.source.value, isString); + const infos = defined(PROPS_TO_DECORATORS.get(path)); + for (const { decoratorPath, importPropDecoratorMap } of infos) { + // Decorators to be imported for the path + // These are typically additional decorators which need to be imported for a path + // For example - `@action` decorator + const decoratorsForPath = edPathNameMap.get(decoratorPath) ?? []; + // delete the visited path to avoid duplicate imports + edPathNameMap.delete(decoratorPath); + + // Create decorator specifiers for which no existing specifiers present in the current path + // e.g. `actions` need not to be imported but `@action` need to be imported from `@ember-decorators/object` + const decoratorSpecifiers = createEmberDecoratorSpecifiers( + decoratorsForPath, + decoratorsToImport + ); + + // The type for value seems to be wrong + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const existingSpecifiers = decoratorImport.value?.specifiers ?? []; + + // Iterate over existing specifiers for the current path. This is needed + // to pick the only required specifiers from the existing imports + // For example - To pick `observer` from `import { get, set, observer } from "@ember/object"` + for (let i = existingSpecifiers.length - 1; i >= 0; i -= 1) { + const existingSpecifier = defined(existingSpecifiers[i]); + + if (isSpecifierDecorator(existingSpecifier, importPropDecoratorMap)) { + // Update decorator local and imported names, + // Needed in case of `observer` which need to be renamed to `@observes` + setSpecifierNames(existingSpecifier, importPropDecoratorMap); + + const isSpecifierPresent = decoratorSpecifiers.some((specifier) => { return ( !specifier.local?.name && specifier.imported.name === existingSpecifier.imported.name ); }); if (!isSpecifierPresent) { - decoratedSpecifiers.push(existingSpecifier); + decoratorSpecifiers.push(existingSpecifier); } - } - // Remove the specifier from the existing import - existingSpecifiers.splice(i, 1); + // Remove the specifier from the existing import + existingSpecifiers.splice(i, 1); + } } - } - if (decoratedSpecifiers.length > 0) { - decoratorPathSpecifierMap[decoratorPath] = [ - ...(decoratorPathSpecifierMap[decoratorPath] ?? []), - ...decoratedSpecifiers, - ]; + if (decoratorSpecifiers.length > 0) { + decoratorPathSpecifierMap[decoratorPath] = [ + ...(decoratorPathSpecifierMap[decoratorPath] ?? []), + ...decoratorSpecifiers, + ]; - if (existingSpecifiers.length <= 0) { - j(decoratorImport).remove(); + if (existingSpecifiers.length <= 0) { + j(decoratorImport).remove(); + } } } } @@ -311,22 +298,19 @@ export function getDecoratorImportInfos( const decoratorImportInfo: DecoratorImportInfoMap = new Map(); for (const decoratorImport of existingDecoratorImports) { - const { importPropDecoratorMap } = defined( - DECORATOR_PATHS.get( - verified(decoratorImport.value.source.value, isString) - ) - ); - + const path = verified(decoratorImport.value.source.value, isString); + const infos = defined(PROPS_TO_DECORATORS.get(path)); const specifiers = decoratorImport.value.specifiers ?? []; - - for (const specifier of specifiers) { - if (isSpecifierDecorator(specifier, importPropDecoratorMap)) { - const localName = specifier.local?.name; - assert(localName, 'expected localName'); - decoratorImportInfo.set( - localName, - getDecoratorImportInfo(specifier, importPropDecoratorMap) - ); + for (const { importPropDecoratorMap } of infos) { + for (const specifier of specifiers) { + if (isSpecifierDecorator(specifier, importPropDecoratorMap)) { + const localName = specifier.local?.name; + assert(localName, 'expected localName'); + decoratorImportInfo.set( + localName, + getDecoratorImportInfo(specifier, importPropDecoratorMap) + ); + } } } } diff --git a/transforms/helpers/options.ts b/transforms/helpers/options.ts index 24c15ff9..0ee9406a 100644 --- a/transforms/helpers/options.ts +++ b/transforms/helpers/options.ts @@ -97,6 +97,9 @@ export const UserOptionsSchema = z.object({ ignoreLeakingState: StringArraySchema.describe( 'Allow-list for ObjectExpression or ArrayExpression properties to ignore issues detailed in eslint-plugin-ember/avoid-leaking-state-in-ember-objects.' ), + noTelemetry: StringBooleanSchema.describe( + 'Disable telemetry. Enabled by default.' + ), type: TypeSchema.describe( 'Apply transformation to only passed type.' ).optional(), @@ -145,7 +148,7 @@ function logConfigError( interface PrivateOptions { /** @private */ - runtimeData: RuntimeData; + runtimeData: RuntimeData | null; } export type Options = UserOptions & PrivateOptions; @@ -156,6 +159,7 @@ export const DEFAULT_OPTIONS: UserOptions = { classicDecorator: true, quote: 'single', ignoreLeakingState: ['queryParams'], + noTelemetry: false, }; class ConfigError extends Error { diff --git a/transforms/helpers/parse-helper.ts b/transforms/helpers/parse-helper.ts index 957f5ee4..b1b8cfeb 100644 --- a/transforms/helpers/parse-helper.ts +++ b/transforms/helpers/parse-helper.ts @@ -1,11 +1,24 @@ -import camelCase from 'camelcase'; import { default as j } from 'jscodeshift'; import path from 'path'; import * as AST from './ast'; -import type { DecoratorImportSpecs } from './util/index'; -import { capitalizeFirstLetter } from './util/index'; +import { classify, type DecoratorImportSpecs } from './util/index'; import { assert, defined, isRecord } from './util/types'; +const DO_NOT_SUFFIX = new Set(['Component', 'Helper', 'EmberObject']); + +// List copied from ember-codemods-telemetry-helpers +const TELEMETRY_TYPES = new Set([ + 'Application', + 'Controller', + 'Route', + 'Component', + 'Service', + 'Helper', + 'Router', + 'Engine', + 'EmberObject', +]); + /** * Get the map of decorators to import other than the computed props, services etc * which already have imports in the code @@ -23,6 +36,7 @@ export function mergeDecoratorImportSpecs( templateLayout: existing.templateLayout || newSpecs.templateLayout, off: existing.off || newSpecs.off, tagName: existing.tagName || newSpecs.tagName, + observes: existing.observes || newSpecs.observes, unobserves: existing.unobserves || newSpecs.unobserves, }; } @@ -74,7 +88,8 @@ export function getExpressionToReplace( export function getClassName( eoExtendExpressionPath: AST.Path, filePath: string, - type = '' + superClassName: string, + type: string | undefined ): string { const varDeclaration = getClosestVariableDeclaration(eoExtendExpressionPath); if (varDeclaration) { @@ -93,22 +108,24 @@ export function getClassName( return identifier.name; } - let className = capitalizeFirstLetter( - camelCase(path.basename(filePath, 'js')) - ); - const capitalizedType = capitalizeFirstLetter(type); + let className = classify(path.basename(filePath, 'js')); - if (capitalizedType === className) { - className = capitalizeFirstLetter( - camelCase(path.basename(path.dirname(filePath))) - ); + // If type is undefined, this means we couldn't find the telemetry or the user + // is running in NO_TELEMETRY mode. In this case, try to infer the type from + // the super class name. + if (!type) { + superClassName = classify(superClassName); + if (TELEMETRY_TYPES.has(superClassName)) { + type = superClassName; + } + } + + if (type === className) { + className = classify(path.basename(path.dirname(filePath))); } - if ( - !['Component', 'Helper', 'EmberObject'].includes(type) && - !className.endsWith(type) - ) { - className = `${className}${capitalizedType}`; + if (type && !DO_NOT_SUFFIX.has(type) && !className.endsWith(type)) { + className = `${className}${type}`; } return className; diff --git a/transforms/helpers/runtime-data.ts b/transforms/helpers/runtime-data.ts index e34dc69b..00278b8c 100644 --- a/transforms/helpers/runtime-data.ts +++ b/transforms/helpers/runtime-data.ts @@ -2,6 +2,7 @@ import { getTelemetryFor } from 'ember-codemods-telemetry-helpers'; import path from 'path'; import { z } from 'zod'; import logger from './log-helper'; +import { isRecord } from './util/types'; const RuntimeDataSchema = z.object({ type: z.string().optional(), @@ -10,6 +11,7 @@ const RuntimeDataSchema = z.object({ overriddenActions: z.array(z.string()).default([]), overriddenProperties: z.array(z.string()).default([]), unobservedProperties: z.record(z.array(z.string())).default({}), + observerProperties: z.record(z.array(z.string())).default({}), }); export type RuntimeData = z.infer; @@ -18,16 +20,16 @@ export type RuntimeData = z.infer; * Gets telemetry data for the file and parses it into a valid `RuntimeData` * object. */ -export function getRuntimeData(filePath: string): RuntimeData { - let rawTelemetry = getTelemetryFor(path.resolve(filePath)); - if (!rawTelemetry) { +export function getRuntimeData(filePath: string): RuntimeData | null { + const rawTelemetry = getTelemetryFor(path.resolve(filePath)); + if (!isRecord(rawTelemetry) || !('type' in rawTelemetry)) { // Do not re-throw. The most likely reason this happened was because // the user's app threw an error. We still want the codemod to work if so. logger.error({ filePath, error: new RuntimeDataError('Could not find runtime data'), }); - rawTelemetry = {}; + return null; } const result = RuntimeDataSchema.safeParse(rawTelemetry); diff --git a/transforms/helpers/transform.ts b/transforms/helpers/transform.ts index 1f38831c..d1e93c44 100644 --- a/transforms/helpers/transform.ts +++ b/transforms/helpers/transform.ts @@ -64,6 +64,7 @@ function _maybeTransformEmberObjects( templateLayout: false, off: false, tagName: false, + observes: false, unobserves: false, }; @@ -77,7 +78,7 @@ function _maybeTransformEmberObjects( } else { const options: Options = { ...userOptions, - runtimeData: getRuntimeData(filePath), + runtimeData: userOptions.noTelemetry ? null : getRuntimeData(filePath), }; // eslint-disable-next-line unicorn/no-array-for-each diff --git a/transforms/helpers/util/index.ts b/transforms/helpers/util/index.ts index 7b456255..6af81018 100644 --- a/transforms/helpers/util/index.ts +++ b/transforms/helpers/util/index.ts @@ -1,3 +1,5 @@ +import camelcase from 'camelcase'; + export const ACTIONS_NAME = 'actions' as const; export type ACTIONS_NAME = typeof ACTIONS_NAME; @@ -10,6 +12,7 @@ export const LAYOUT_DECORATOR_LOCAL_NAME = 'templateLayout' as const; export const LAYOUT_DECORATOR_NAME = 'layout' as const; export const OFF_DECORATOR_NAME = 'off' as const; export const TAG_NAME_DECORATOR_NAME = 'tagName' as const; +export const OBSERVES_DECORATOR_NAME = 'observes' as const; export const UNOBSERVES_DECORATOR_NAME = 'unobserves' as const; export type ATTRIBUTE_BINDINGS_DECORATOR_NAME = typeof ATTRIBUTE_BINDINGS_DECORATOR_NAME; @@ -19,7 +22,6 @@ export type CLASS_NAMES_DECORATOR_NAME = typeof CLASS_NAMES_DECORATOR_NAME; export type LAYOUT_DECORATOR_NAME = typeof LAYOUT_DECORATOR_NAME; export type TAG_NAME_DECORATOR_NAME = typeof TAG_NAME_DECORATOR_NAME; -const OBSERVES_DECORATOR_NAME = 'observes' as const; const ON_DECORATOR_NAME = 'on' as const; interface DecoratorPathInfo { @@ -36,59 +38,83 @@ export interface DecoratorImportSpecs { [LAYOUT_DECORATOR_LOCAL_NAME]: boolean; [OFF_DECORATOR_NAME]: boolean; [TAG_NAME_DECORATOR_NAME]: boolean; + [OBSERVES_DECORATOR_NAME]: boolean; [UNOBSERVES_DECORATOR_NAME]: boolean; } -export const DECORATOR_PATHS: ReadonlyMap = new Map([ - [ - '@ember/object', - { - importPropDecoratorMap: { - observer: OBSERVES_DECORATOR_NAME, - computed: COMPUTED_DECORATOR_NAME, - }, - decoratorPath: '@ember/object', - }, - ], - [ - '@ember/object/evented', - { - importPropDecoratorMap: { - on: ON_DECORATOR_NAME, - }, - decoratorPath: '@ember-decorators/object', - }, - ], - [ - '@ember/controller', - { - importPropDecoratorMap: { - inject: 'inject', - }, - decoratorPath: '@ember/controller', - }, - ], - [ - '@ember/service', - { - importPropDecoratorMap: { - inject: 'inject', - service: 'service', - }, - decoratorPath: '@ember/service', - }, - ], - [ - '@ember/object/computed', - { - decoratorPath: '@ember/object/computed', - }, - ], -]); - -export const DECORATOR_PATH_OVERRIDES: ReadonlyMap = new Map([ - [OBSERVES_DECORATOR_NAME, '@ember-decorators/object'], -]); +export const PROPS_TO_DECORATORS: ReadonlyMap = + new Map([ + [ + '@ember/object', + [ + { + decoratorPath: '@ember-decorators/object', + importPropDecoratorMap: { observer: OBSERVES_DECORATOR_NAME }, + }, + { + decoratorPath: '@ember/object', + importPropDecoratorMap: { + [COMPUTED_DECORATOR_NAME]: COMPUTED_DECORATOR_NAME, + }, + }, + ], + ], + [ + '@ember/object/evented', + [ + { + decoratorPath: '@ember-decorators/object', + importPropDecoratorMap: { + [ON_DECORATOR_NAME]: ON_DECORATOR_NAME, + }, + }, + { + decoratorPath: '@ember-decorators/object', + importPropDecoratorMap: { + [OFF_DECORATOR_NAME]: OFF_DECORATOR_NAME, + }, + }, + ], + ], + [ + '@ember/controller', + [ + { + decoratorPath: '@ember/controller', + importPropDecoratorMap: { + inject: 'inject', + }, + }, + ], + ], + [ + '@ember/service', + [ + { + decoratorPath: '@ember/service', + importPropDecoratorMap: { + // FIXME: service? + inject: 'inject', + }, + }, + { + decoratorPath: '@ember/service', + importPropDecoratorMap: { + service: 'service', + }, + }, + ], + ], + // FIXME: Do we need this? + [ + '@ember/object/computed', + [ + { + decoratorPath: '@ember/object/computed', + }, + ], + ], + ]); export const CLASS_DECORATOR_NAMES = [ ATTRIBUTE_BINDINGS_DECORATOR_NAME, @@ -285,7 +311,9 @@ export function allowObjectLiteralDecorator( ); } -/** Convert the first letter to uppercase */ -export function capitalizeFirstLetter(name: string): string { - return name ? name.charAt(0).toUpperCase() + name.slice(1) : ''; +/** + * Returns a PascalCase version of the given string. + */ +export function classify(name: string): string { + return camelcase(name, { pascalCase: true }); }