diff --git a/package-lock.json b/package-lock.json index afd88678..3cb60286 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "devDependencies": { "@commitlint/cli": "^19.2.1", "@commitlint/config-conventional": "^19.1.0", - "@ibm/telemetry-attributes-js": "^3.0.2", + "@ibm/telemetry-attributes-js": "^3.1.0", "@ibm/telemetry-config-schema": "^1.1.0", "@opentelemetry/api": "^1.8.0", "@opentelemetry/exporter-metrics-otlp-http": "^0.50.0", @@ -1195,9 +1195,9 @@ "dev": true }, "node_modules/@ibm/telemetry-attributes-js": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@ibm/telemetry-attributes-js/-/telemetry-attributes-js-3.0.2.tgz", - "integrity": "sha512-4Qrijji9NE1YmzGjQwc2oWHiE2lq0LzQSBc54P025p6zzlMQBVLFaLVjGYfLhI4/RFIzoUaplI7NVqBC43mSXA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@ibm/telemetry-attributes-js/-/telemetry-attributes-js-3.1.0.tgz", + "integrity": "sha512-8BHkAqxheud9rLsbZv4XbTlOm7pNaRDMspdd2qtKrbDjeXaRfaU90wUOkwwuEA9xg+7kNFAeTxUJtEE8FKID9Q==", "dev": true }, "node_modules/@ibm/telemetry-config-schema": { diff --git a/package.json b/package.json index f55b1e40..069f87c4 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "devDependencies": { "@commitlint/cli": "^19.2.1", "@commitlint/config-conventional": "^19.1.0", - "@ibm/telemetry-attributes-js": "^3.0.2", + "@ibm/telemetry-attributes-js": "^3.1.0", "@ibm/telemetry-config-schema": "^1.1.0", "@opentelemetry/api": "^1.8.0", "@opentelemetry/exporter-metrics-otlp-http": "^0.50.0", diff --git a/sonar-project.properties b/sonar-project.properties index 641723d4..f539b218 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -22,3 +22,7 @@ sonar.coverage.exclusions=\ # Patterns used to exclude some files from duplication report sonar.cpd.exclusions=\ **/src/test/**/* + +# Patterns to exclude from scanning altogether +sonar.exclusions=\ + src/test/__fixtures/**/* diff --git a/src/main/core/anonymize/substitute-array.ts b/src/main/core/anonymize/substitute-array.ts new file mode 100644 index 00000000..98229c26 --- /dev/null +++ b/src/main/core/anonymize/substitute-array.ts @@ -0,0 +1,34 @@ +/* + * Copyright IBM Corp. 2023, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Substitution } from './substitution.js' + +/** + * Substitutes values in the raw input based on the provided allowed values. + * + * @param raw - An array of values. + * @param allowedValues - The values to leave untouched. + * @returns The raw array with all non-allowed values replaced with anonymized versions of their + * values. + */ +export function substituteArray(raw: unknown[], allowedValues: unknown[]): unknown[] { + const subs = new Substitution() + + return raw.map((value) => { + // Value is a string that's not safe + if (typeof value === 'string' && !allowedValues.includes(value)) { + return subs.put(value) + } + + // Value is an object that's not null and not safe + if (typeof value === 'object' && value !== null && !allowedValues.includes(value)) { + return subs.put(value) + } + + return value + }) +} diff --git a/src/main/core/anonymize/substitute.ts b/src/main/core/anonymize/substitute-object.ts similarity index 66% rename from src/main/core/anonymize/substitute.ts rename to src/main/core/anonymize/substitute-object.ts index 620f38ff..4c7573e9 100644 --- a/src/main/core/anonymize/substitute.ts +++ b/src/main/core/anonymize/substitute-object.ts @@ -4,10 +4,8 @@ * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ -import { TypedKeyMap } from './typed-key-map.js' -const subs = new TypedKeyMap() -let curSub = 1 +import { Substitution } from './substitution.js' /** * Substitutes keys/values in the raw input based on the provided allowed keys and values. @@ -18,40 +16,30 @@ let curSub = 1 * @returns The raw object with all specified keys replaced with anonymized versions of their * values. */ -export function substitute>( +export function substituteObject>( raw: T, allowedKeys: Array, allowedValues: unknown[] ): { [Property in keyof T]: T[Property] extends object ? string : T[Property] } { + const subs = new Substitution() + const substitutedEntries = Object.entries(raw).map(([key, value]) => { // Key is not safe. Substitute key and value if (!allowedKeys.includes(key)) { - if (!subs.has(key)) { - subs.set(key, nextSub()) - } - if (!subs.has(value)) { - subs.set(value, nextSub()) - } + const newKey = subs.put(key) + const newVal = subs.put(value) - return [subs.get(key), subs.get(value)] + return [newKey, newVal] } // Key is safe. Value is a string that's not safe if (typeof value === 'string' && !allowedValues.includes(value)) { - if (!subs.has(value)) { - subs.set(value, nextSub()) - } - - return [key, subs.get(value)] + return [key, subs.put(value)] } // Key is safe. Value is an object that's not null and not safe if (typeof value === 'object' && value !== null && !allowedValues.includes(value)) { - if (!subs.has(value)) { - subs.set(value, nextSub()) - } - - return [key, subs.get(value)] + return [key, subs.put(value)] } // Both key and value are safe @@ -60,7 +48,3 @@ export function substitute>( return Object.fromEntries(substitutedEntries) } - -function nextSub() { - return `[redacted${curSub++}]` -} diff --git a/src/main/core/anonymize/substitution.ts b/src/main/core/anonymize/substitution.ts new file mode 100644 index 00000000..e2c8e36e --- /dev/null +++ b/src/main/core/anonymize/substitution.ts @@ -0,0 +1,36 @@ +/* + * Copyright IBM Corp. 2024, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import { TypedKeyMap } from './typed-key-map.js' + +const subs = new TypedKeyMap() +let curSub = 1 + +function nextSub() { + return `[redacted${curSub++}]` +} + +/** + * Encapsulates logic that tracks substitution values across a telemetry run. + */ +export class Substitution { + /** + * Puts a given value into the substitution map and returns its substituted/anonymized equivalent. + * + * @param key - The value to substitute. + * @returns An anonymized representation of the key (using substitution). + */ + public put(key: unknown): string { + if (subs.has(key)) { + return subs.get(key) + } + + const next = nextSub() + subs.set(key, next) + + return next + } +} diff --git a/src/main/scopes/js/complex-value.ts b/src/main/scopes/js/complex-value.ts index 7956c288..c173ca40 100644 --- a/src/main/scopes/js/complex-value.ts +++ b/src/main/scopes/js/complex-value.ts @@ -9,5 +9,5 @@ * Object representing a complex value. */ export class ComplexValue { - constructor(public complexValue: unknown) {} + constructor(public readonly complexValue: unknown) {} } diff --git a/src/main/scopes/js/find-relevant-source-files.ts b/src/main/scopes/js/find-relevant-source-files.ts index 835df025..d6376949 100644 --- a/src/main/scopes/js/find-relevant-source-files.ts +++ b/src/main/scopes/js/find-relevant-source-files.ts @@ -73,17 +73,19 @@ export async function findRelevantSourceFiles( } while (shortestPathLength === undefined && packageTrees.length > 0) if (instrumentedInstallVersions === undefined) { - throw new NoInstallationFoundError(instrumentedPackage.name) + logger.error(new NoInstallationFoundError(instrumentedPackage.name)) + return false } return instrumentedInstallVersions.some((version) => version === instrumentedPackage.version) }) + const filterData = await Promise.all(filterPromises) const results = sourceFiles.filter((_, index) => { return filterData[index] }) - logger.traceEnter('', 'findRelevantSourceFiles', results) + logger.traceExit('', 'findRelevantSourceFiles', results) return results } diff --git a/src/main/scopes/js/get-access-path.ts b/src/main/scopes/js/get-access-path.ts new file mode 100644 index 00000000..af584553 --- /dev/null +++ b/src/main/scopes/js/get-access-path.ts @@ -0,0 +1,84 @@ +/* + * Copyright IBM Corp. 2024, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import * as ts from 'typescript' + +import type { Logger } from '../../core/log/logger.js' +import { ComplexValue } from './complex-value.js' +import { getNodeValueHandler } from './node-value-handler-map.js' + +type ExpressionContainingNode = + | ts.PropertyAccessExpression + | ts.ElementAccessExpression + | ts.CallExpression + +/** + * Constructs the access path to a given expression-containing node + * (PropertyAccessExpression, ElementAccessExpression or CallExpression) + * by reading the node's content recursively. + * + * @param node - TS node to retrieve access path for. + * @param sourceFile - Root AST node. + * @param logger - A logger instance. + * @returns Access path to node represented as string array (chunks). + */ +export default function getAccessPath( + node: ExpressionContainingNode, + sourceFile: ts.SourceFile, + logger: Logger +) { + return computeAccessPath(node, sourceFile, [], true, logger) +} + +/** + * Constructs the access path to a given node by reading the node's content recursively. + * + * @param node - TS node to retrieve access path for. + * @param sourceFile - Root AST node. + * @param currAccessPath - For internal use only, tracks current constructed access path. + * @param topLevel - For internal use only, tracks top level function call. + * @param logger - A logger instance. + * @returns Access path to node represented as string array (chunks). + */ +function computeAccessPath( + node: ts.Node, + sourceFile: ts.SourceFile, + currAccessPath: Array, + topLevel: boolean, + logger: Logger +): Array { + switch (node.kind) { + case ts.SyntaxKind.Identifier: + return [...currAccessPath, (node as ts.Identifier).escapedText.toString()] + case ts.SyntaxKind.PropertyAccessExpression: + currAccessPath.push((node as ts.PropertyAccessExpression).name.escapedText.toString()) + break + case ts.SyntaxKind.ElementAccessExpression: { + const argumentExpression = (node as ts.ElementAccessExpression).argumentExpression + const data = getNodeValueHandler(argumentExpression.kind, sourceFile, logger).getData( + argumentExpression + ) + if (data !== undefined && data !== null) { + currAccessPath.push(data instanceof ComplexValue ? data : data.toString()) + } + break + } + } + + let accessPath = currAccessPath + + if ('expression' in node) { + accessPath = computeAccessPath( + node.expression as ts.Node, + sourceFile, + currAccessPath, + false, + logger + ) + } + + return topLevel ? accessPath.reverse() : accessPath +} diff --git a/src/main/scopes/js/import-matchers/functions/js-function-all-import-matcher.ts b/src/main/scopes/js/import-matchers/functions/js-function-all-import-matcher.ts deleted file mode 100644 index 4e7e5968..00000000 --- a/src/main/scopes/js/import-matchers/functions/js-function-all-import-matcher.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright IBM Corp. 2024, 2024 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ - -import type { JsFunction, JsImport, JsImportMatcher } from '../../interfaces.js' - -/** - * Identifies JsFunctions that have been imported as all imports, - * and returns an import element match (if any) or undefined otherwise. - */ -export class JsFunctionAllImportMatcher implements JsImportMatcher { - /** - * Determines if a given JsFunction is an all(*) import - * (.e.g: import * as something from 'package') - * that matches the supplied list of import elements. - * - * @param _function - JsFunction to evaluate. - * @param _imports - Import elements to use for comparison. - * @returns Corresponding JsImport element if function was imported as an all import, - * undefined otherwise. - */ - findMatch(_function: JsFunction, _imports: JsImport[]) { - // TODO: implement - return undefined - } -} diff --git a/src/main/scopes/js/import-matchers/functions/js-function-named-import-matcher.ts b/src/main/scopes/js/import-matchers/functions/js-function-named-import-matcher.ts deleted file mode 100644 index 8261c928..00000000 --- a/src/main/scopes/js/import-matchers/functions/js-function-named-import-matcher.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright IBM Corp. 2024, 2024 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ - -import type { JsFunction, JsImport, JsImportMatcher } from '../../interfaces.js' - -/** - * Identifies JsFunctions that have been imported as named imports, - * and returns an import element match (if any) or undefined otherwise. - */ -export class JsFunctionNamedImportMatcher implements JsImportMatcher { - /** - * Determines if a given JsFunction is a named import (e.g.: `import {something} from 'package'`). - * - * @param _function - JsFunction to evaluate. - * @param _imports - Import elements to use for comparison. - * @returns Corresponding JsImport if function was imported as a name import, - * undefined otherwise. - */ - findMatch(_function: JsFunction, _imports: JsImport[]) { - // TODO: implement - return undefined - } -} diff --git a/src/main/scopes/js/import-matchers/functions/js-function-renamed-import-matcher.ts b/src/main/scopes/js/import-matchers/functions/js-function-renamed-import-matcher.ts deleted file mode 100644 index 2e71ee0d..00000000 --- a/src/main/scopes/js/import-matchers/functions/js-function-renamed-import-matcher.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright IBM Corp. 2024, 2024 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ - -import type { JsFunction, JsImport, JsImportMatcher } from '../../interfaces.js' - -/** - * Identifies JsFunctions that have been imported as renamed imports. - */ -export class JsFunctionRenamedImportMatcher implements JsImportMatcher { - /** - * Determines if a given JsFunction is a renamed import - * (e.g.: `import {something as somethingElse} from 'package'`) - * and returns an import element match (if any) or undefined otherwise. - * - * @param _function - JsFunction to evaluate. - * @param _imports - Import elements to use for comparison. - * @returns Corresponding JsImport if function was imported as a renamed import, - * undefined otherwise. - */ - findMatch(_function: JsFunction, _imports: JsImport[]) { - // TODO: implement - return undefined - } -} diff --git a/src/main/scopes/js/import-matchers/js-all-import-matcher.ts b/src/main/scopes/js/import-matchers/js-all-import-matcher.ts new file mode 100644 index 00000000..fafe617c --- /dev/null +++ b/src/main/scopes/js/import-matchers/js-all-import-matcher.ts @@ -0,0 +1,31 @@ +/* + * Copyright IBM Corp. 2024, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { JsFunction, JsImport, JsImportMatcher, JsToken } from '../interfaces.js' + +/** + * Import matcher for all (*) imports, such as import * as stuff from 'whatever'. + */ +export class JsAllImportMatcher implements JsImportMatcher { + /** + * Determines if a given JsToken or JsFunction is an all(*) import + * (.e.g: import * as something from 'package') + * that matches the supplied list of import elements. + * + * @param jsElement - JsToken or JsFunction to evaluate. + * @param imports - Import elements to use for comparison. + * @returns Corresponding JsImport element if token was imported as an all import, + * undefined otherwise. + */ + findMatch(jsElement: JsToken | JsFunction, imports: JsImport[]) { + // checking that accessPath length is at least two + // since we'd expect the token/function to be accessed from within the import + return jsElement.accessPath.length >= 2 + ? imports.find((i) => i.isAll && i.name === jsElement.accessPath[0]) + : undefined + } +} diff --git a/src/main/scopes/js/import-matchers/js-named-import-matcher.ts b/src/main/scopes/js/import-matchers/js-named-import-matcher.ts new file mode 100644 index 00000000..b8cb4495 --- /dev/null +++ b/src/main/scopes/js/import-matchers/js-named-import-matcher.ts @@ -0,0 +1,29 @@ +/* + * Copyright IBM Corp. 2024, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { JsFunction, JsImport, JsImportMatcher, JsToken } from '../interfaces.js' + +/** + * Import matcher for all named imports, such as import { stuff } from 'whatever'. + */ +export class JsNamedImportMatcher implements JsImportMatcher { + /** + * Determines if a given JsToken or JsFunction is a named import + * (e.g.: `import {something} from 'package'`). + * + * @param jsElement - JsToken or JsFunction to evaluate. + * @param imports - Import elements to use for comparison. + * @returns Corresponding JsImport if token was imported as a name import, + * undefined otherwise. + */ + findMatch(jsElement: JsToken | JsFunction, imports: JsImport[]) { + return imports.find( + (i) => + !i.isDefault && !i.isAll && i.rename === undefined && i.name === jsElement.accessPath[0] + ) + } +} diff --git a/src/main/scopes/js/import-matchers/js-renamed-import-matcher.ts b/src/main/scopes/js/import-matchers/js-renamed-import-matcher.ts new file mode 100644 index 00000000..4d65cc1c --- /dev/null +++ b/src/main/scopes/js/import-matchers/js-renamed-import-matcher.ts @@ -0,0 +1,27 @@ +/* + * Copyright IBM Corp. 2024, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { JsFunction, JsImport, JsImportMatcher, JsToken } from '../interfaces.js' + +/** + * Import matcher for all renamed imports, such as import { stuff as renamedStuff } from 'whatever'. + */ +export class JsRenamedImportMatcher implements JsImportMatcher { + /** + * Determines if a given JsToken or JsFunction is a renamed import + * (e.g.: `import {something as somethingElse} from 'package'`) + * and returns an import element match (if any) or undefined otherwise. + * + * @param jsElement - JsToken or JsFunction to evaluate. + * @param imports - Import elements to use for comparison. + * @returns Corresponding JsImport if token was imported as a renamed import, + * undefined otherwise. + */ + findMatch(jsElement: JsToken | JsFunction, imports: JsImport[]) { + return imports.find((i) => i.rename !== undefined && i.rename === jsElement.accessPath[0]) + } +} diff --git a/src/main/scopes/js/import-matchers/tokens/js-token-all-import-matcher.ts b/src/main/scopes/js/import-matchers/tokens/js-token-all-import-matcher.ts deleted file mode 100644 index 39ab73cc..00000000 --- a/src/main/scopes/js/import-matchers/tokens/js-token-all-import-matcher.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright IBM Corp. 2024, 2024 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ - -import type { JsImport, JsImportMatcher, JsToken } from '../../interfaces.js' - -/** - * Identifies JsTokens that have been imported as all imports, - * and returns an import element match (if any) or undefined otherwise. - */ -export class JsTokenAllImportMatcher implements JsImportMatcher { - /** - * Determines if a given JsToken is an all(*) import - * (.e.g: import * as something from 'package') - * that matches the supplied list of import elements. - * - * @param _token - JsToken to evaluate. - * @param _imports - Import elements to use for comparison. - * @returns Corresponding JsImport element if token was imported as an all import, - * undefined otherwise. - */ - findMatch(_token: JsToken, _imports: JsImport[]) { - // TODO: implement - return undefined - } -} diff --git a/src/main/scopes/js/import-matchers/tokens/js-token-named-import-matcher.ts b/src/main/scopes/js/import-matchers/tokens/js-token-named-import-matcher.ts deleted file mode 100644 index 802105b4..00000000 --- a/src/main/scopes/js/import-matchers/tokens/js-token-named-import-matcher.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright IBM Corp. 2024, 2024 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ - -import type { JsImport, JsImportMatcher, JsToken } from '../../interfaces.js' - -/** - * Identifies JsTokens that have been imported as named imports, - * and returns an import element match (if any) or undefined otherwise. - */ -export class JsTokenNamedImportMatcher implements JsImportMatcher { - /** - * Determines if a given JsToken is a named import (e.g.: `import {something} from 'package'`). - * - * @param _token - JsToken to evaluate. - * @param _imports - Import elements to use for comparison. - * @returns Corresponding JsImport if token was imported as a name import, - * undefined otherwise. - */ - findMatch(_token: JsToken, _imports: JsImport[]) { - // TODO: implement - return undefined - } -} diff --git a/src/main/scopes/js/import-matchers/tokens/js-token-renamed-import-matcher.ts b/src/main/scopes/js/import-matchers/tokens/js-token-renamed-import-matcher.ts deleted file mode 100644 index c4a2ce79..00000000 --- a/src/main/scopes/js/import-matchers/tokens/js-token-renamed-import-matcher.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright IBM Corp. 2024, 2024 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ - -import type { JsImport, JsImportMatcher, JsToken } from '../../interfaces.js' - -/** - * Identifies JsTokens that have been imported as renamed imports. - */ -export class JsTokenRenamedImportMatcher implements JsImportMatcher { - /** - * Determines if a given JsToken is a renamed import - * (e.g.: `import {something as somethingElse} from 'package'`) - * and returns an import element match (if any) or undefined otherwise. - * - * @param _token - JsToken to evaluate. - * @param _imports - Import elements to use for comparison. - * @returns Corresponding JsImport if token was imported as a renamed import, - * undefined otherwise. - */ - findMatch(_token: JsToken, _imports: JsImport[]) { - // TODO: implement - return undefined - } -} diff --git a/src/main/scopes/js/interfaces.ts b/src/main/scopes/js/interfaces.ts index 400a52dd..ce0dbb74 100644 --- a/src/main/scopes/js/interfaces.ts +++ b/src/main/scopes/js/interfaces.ts @@ -38,11 +38,15 @@ export interface JsImportMatcher { export interface JsFunction { name: string - accessPath: string + accessPath: Array arguments: Array + startPos: number + endPos: number } export interface JsToken { name: string - accessPath: string + accessPath: Array + startPos: number + endPos: number } diff --git a/src/main/scopes/js/js-accumulator.ts b/src/main/scopes/js/js-accumulator.ts index da971603..6c9c331c 100644 --- a/src/main/scopes/js/js-accumulator.ts +++ b/src/main/scopes/js/js-accumulator.ts @@ -11,7 +11,7 @@ import type { JsImport } from '../js/interfaces.js' * Responsible for maintaining an aggregated state of imports and other elements. */ export abstract class JsAccumulator { - public readonly imports: JsImport[] + public imports: JsImport[] constructor() { this.imports = [] diff --git a/src/main/scopes/js/js-function-token-accumulator.ts b/src/main/scopes/js/js-function-token-accumulator.ts index 114d6992..ce64919b 100644 --- a/src/main/scopes/js/js-function-token-accumulator.ts +++ b/src/main/scopes/js/js-function-token-accumulator.ts @@ -11,8 +11,8 @@ import { JsAccumulator } from '../js/js-accumulator.js' * Responsible for maintaining an aggregated state of imports, functions and tokens. */ export class JsFunctionTokenAccumulator extends JsAccumulator { - public readonly tokens: JsToken[] - public readonly functions: JsFunction[] + public tokens: JsToken[] + public functions: JsFunction[] public readonly functionImports: Map public readonly tokenImports: Map diff --git a/src/main/scopes/js/js-node-handler-map.ts b/src/main/scopes/js/js-node-handler-map.ts index de39f5d6..77791be3 100644 --- a/src/main/scopes/js/js-node-handler-map.ts +++ b/src/main/scopes/js/js-node-handler-map.ts @@ -8,10 +8,9 @@ import * as ts from 'typescript' import type { JsNodeHandlerMap } from '../js/interfaces.js' import { ImportNodeHandler } from '../js/node-handlers/import-node-handler.js' +import { AccessExpressionNodeHandler } from './node-handlers/tokens-and-functions-handlers/access-expression-node-handler.js' import { CallExpressionNodeHandler } from './node-handlers/tokens-and-functions-handlers/call-expression-node-handler.js' -import { ElementAccessExpressionNodeHandler } from './node-handlers/tokens-and-functions-handlers/element-access-expression-node-handler.js' import { IdentifierNodeHandler } from './node-handlers/tokens-and-functions-handlers/identifier-node-handler.js' -import { PropertyAccessExpressionNodeHandler } from './node-handlers/tokens-and-functions-handlers/property-access-expression-node-handler.js' /** * Maps node kinds to handlers that know how to process them to generate JsFunction and JsToken @@ -20,7 +19,7 @@ import { PropertyAccessExpressionNodeHandler } from './node-handlers/tokens-and- export const jsNodeHandlerMap: JsNodeHandlerMap = { [ts.SyntaxKind.ImportDeclaration]: ImportNodeHandler, [ts.SyntaxKind.CallExpression]: CallExpressionNodeHandler, - [ts.SyntaxKind.PropertyAccessExpression]: PropertyAccessExpressionNodeHandler, - [ts.SyntaxKind.ElementAccessExpression]: ElementAccessExpressionNodeHandler, + [ts.SyntaxKind.PropertyAccessExpression]: AccessExpressionNodeHandler, + [ts.SyntaxKind.ElementAccessExpression]: AccessExpressionNodeHandler, [ts.SyntaxKind.Identifier]: IdentifierNodeHandler } diff --git a/src/main/scopes/js/js-scope.ts b/src/main/scopes/js/js-scope.ts index 3de14734..fe912fe2 100644 --- a/src/main/scopes/js/js-scope.ts +++ b/src/main/scopes/js/js-scope.ts @@ -16,12 +16,9 @@ import { processFile } from '../js/process-file.js' import { removeIrrelevantImports } from '../js/remove-irrelevant-imports.js' import { getPackageData } from '../npm/get-package-data.js' import type { PackageData } from '../npm/interfaces.js' -import { JsFunctionAllImportMatcher } from './import-matchers/functions/js-function-all-import-matcher.js' -import { JsFunctionNamedImportMatcher } from './import-matchers/functions/js-function-named-import-matcher.js' -import { JsFunctionRenamedImportMatcher } from './import-matchers/functions/js-function-renamed-import-matcher.js' -import { JsTokenAllImportMatcher } from './import-matchers/tokens/js-token-all-import-matcher.js' -import { JsTokenNamedImportMatcher } from './import-matchers/tokens/js-token-named-import-matcher.js' -import { JsTokenRenamedImportMatcher } from './import-matchers/tokens/js-token-renamed-import-matcher.js' +import { JsAllImportMatcher } from './import-matchers/js-all-import-matcher.js' +import { JsNamedImportMatcher } from './import-matchers/js-named-import-matcher.js' +import { JsRenamedImportMatcher } from './import-matchers/js-renamed-import-matcher.js' import { JsFunctionTokenAccumulator } from './js-function-token-accumulator.js' import { jsNodeHandlerMap } from './js-node-handler-map.js' import { FunctionMetric } from './metrics/function-metric.js' @@ -71,17 +68,10 @@ export class JsScope extends Scope { async captureAllMetrics( collectorKeys: NonNullable ): Promise { - // TODO: these might end up becoming one and the same (same matchers for functions and tokens) - const functionImportMatchers: JsImportMatcher[] = [ - new JsFunctionAllImportMatcher(), - new JsFunctionNamedImportMatcher(), - new JsFunctionRenamedImportMatcher() - ] - - const tokenImportMatchers: JsImportMatcher[] = [ - new JsTokenAllImportMatcher(), - new JsTokenNamedImportMatcher(), - new JsTokenRenamedImportMatcher() + const importMatchers: JsImportMatcher[] = [ + new JsAllImportMatcher(), + new JsNamedImportMatcher(), + new JsRenamedImportMatcher() ] const instrumentedPackage = await getPackageData(this.cwd, this.cwd, this.logger) @@ -102,8 +92,7 @@ export class JsScope extends Scope { const resultPromise = this.captureFileMetrics( sourceFile, instrumentedPackage, - tokenImportMatchers, - functionImportMatchers, + importMatchers, collectorKeys ) @@ -124,20 +113,23 @@ export class JsScope extends Scope { * @param sourceFile - The sourcefile node to generate metrics for. * @param instrumentedPackage - Name and version of the instrumented package * to capture metrics for. - * @param tokenImportMatchers - Matchers instances to use for import-token matching. - * @param functionImportMatchers - Matchers instances to use for import-function matching. + * @param importMatchers - Matchers instances to use for import-function/token matching. * @param collectorKeys - Config keys defined for JS scope. */ async captureFileMetrics( sourceFile: ts.SourceFile, instrumentedPackage: PackageData, - tokenImportMatchers: JsImportMatcher[], - functionImportMatchers: JsImportMatcher[], + importMatchers: JsImportMatcher[], collectorKeys: NonNullable ) { const accumulator = new JsFunctionTokenAccumulator() processFile(accumulator, sourceFile, jsNodeHandlerMap, this.logger) + + this.deduplicateFunctions(accumulator) + + this.deduplicateTokens(accumulator) + removeIrrelevantImports(accumulator, instrumentedPackage.name) const promises: Array> = [] @@ -146,16 +138,12 @@ export class JsScope extends Scope { switch (key) { case 'tokens': promises.push( - this.captureTokenFileMetrics(accumulator, instrumentedPackage, tokenImportMatchers) + this.captureTokenFileMetrics(accumulator, instrumentedPackage, importMatchers) ) break case 'functions': promises.push( - this.captureFunctionFileMetrics( - accumulator, - instrumentedPackage, - functionImportMatchers - ) + this.captureFunctionFileMetrics(accumulator, instrumentedPackage, importMatchers) ) break } @@ -254,6 +242,27 @@ export class JsScope extends Scope { }) } + deduplicateFunctions(accumulator: JsFunctionTokenAccumulator) { + accumulator.functions = accumulator.functions.filter( + (f) => + !accumulator.functions.some( + (func) => func.startPos >= f.startPos && func.endPos <= f.endPos && func !== f + ) + ) + } + + deduplicateTokens(accumulator: JsFunctionTokenAccumulator) { + // Given: foo.bar().baz + // foo.bar().baz <- filtered + // foo.bar <-- function, not touched + accumulator.tokens = accumulator.tokens.filter( + (token) => + !accumulator.functions.some( + (func) => func.startPos >= token.startPos && func.endPos <= token.endPos + ) + ) + } + /** * **For testing purposes only.** * Makes the JsxScope collection run "synchronously" (one source file at a time). Defaults to diff --git a/src/main/scopes/js/metrics/function-metric.ts b/src/main/scopes/js/metrics/function-metric.ts index 89a69599..6be8b416 100644 --- a/src/main/scopes/js/metrics/function-metric.ts +++ b/src/main/scopes/js/metrics/function-metric.ts @@ -9,11 +9,14 @@ import type { ConfigSchema } from '@ibm/telemetry-config-schema' import type { Attributes } from '@opentelemetry/api' import { hash } from '../../../core/anonymize/hash.js' -import { substitute } from '../../../core/anonymize/substitute.js' +import { substituteArray } from '../../../core/anonymize/substitute-array.js' +import { Substitution } from '../../../core/anonymize/substitution.js' import type { Logger } from '../../../core/log/logger.js' +import { safeStringify } from '../../../core/log/safe-stringify.js' import { PackageDetailsProvider } from '../../../core/package-details-provider.js' import { ScopeMetric } from '../../../core/scope-metric.js' import type { PackageData } from '../../npm/interfaces.js' +import { ComplexValue } from '../complex-value.js' import type { JsFunction, JsImport } from '../interfaces.js' /** @@ -27,23 +30,23 @@ export class FunctionMetric extends ScopeMetric { private readonly instrumentedPackage: PackageData /** - * Constructs a TokenMetric. + * Constructs a FunctionMetric. * - * @param jsToken - Object containing token data to generate metric from. + * @param jsFunction - Object containing function data to generate metric from. * @param matchingImport - Import that matched the provided JsFunction in the file. * @param instrumentedPackage - Data (name and version) pertaining to instrumented package. * @param config - Determines which argument values to collect for. * @param logger - Logger instance. */ public constructor( - jsToken: JsFunction, + jsFunction: JsFunction, matchingImport: JsImport, instrumentedPackage: PackageData, config: ConfigSchema, logger: Logger ) { super(logger) - this.jsFunction = jsToken + this.jsFunction = jsFunction this.matchingImport = matchingImport this.instrumentedPackage = instrumentedPackage @@ -71,24 +74,50 @@ export class FunctionMetric extends ScopeMetric { this.instrumentedPackage.version ) - // convert arguments into a fake object to satisfy the substitute api - const argsObj: Record = this.jsFunction.arguments.reduce( - (cur, val, index) => ({ ...cur, [index.toString()]: val }), - {} - ) + let anonymizedFunctionName = safeStringify(this.jsFunction.name) + const anonymizedFunctionAccessPath = [...this.jsFunction.accessPath] - const anonymizedArguments = substitute( - argsObj, - Object.keys(argsObj), // all keys are allowed - this.allowedArgumentStringValues - ) + // Handle renamed functions + if (this.matchingImport.rename !== undefined) { + anonymizedFunctionName = anonymizedFunctionName.replace( + this.matchingImport.rename, + this.matchingImport.name + ) + // replace the import name in access path + anonymizedFunctionAccessPath[0] = this.matchingImport.name + } + + const subs = new Substitution() + // redact complex values + anonymizedFunctionAccessPath.forEach((segment) => { + if (segment instanceof ComplexValue) { + anonymizedFunctionName = anonymizedFunctionName.replace( + safeStringify(segment.complexValue), + subs.put(segment) + ) + } + }) + + // redact "all" import + if (this.matchingImport.isAll) { + anonymizedFunctionAccessPath[0] = subs.put(this.matchingImport.name) + anonymizedFunctionName = anonymizedFunctionName.replace( + this.matchingImport.name, + subs.put(this.matchingImport.name) + ) + } let metricData: Attributes = { - [JsScopeAttributes.FUNCTION_NAME]: this.jsFunction.name, - [JsScopeAttributes.FUNCTION_ACCESS_PATH]: this.jsFunction.accessPath, - [JsScopeAttributes.FUNCTION_ARGUMENT_VALUES]: Object.values(anonymizedArguments).map((arg) => - String(arg) - ), + [JsScopeAttributes.FUNCTION_NAME]: anonymizedFunctionName, + [JsScopeAttributes.FUNCTION_MODULE_SPECIFIER]: this.matchingImport.path, + [JsScopeAttributes.FUNCTION_ACCESS_PATH]: substituteArray( + anonymizedFunctionAccessPath, + anonymizedFunctionAccessPath.filter((p) => typeof p === 'string') + ).join(' '), + [JsScopeAttributes.FUNCTION_ARGUMENT_VALUES]: substituteArray( + this.jsFunction.arguments, + this.allowedArgumentStringValues + ).map((arg) => String(arg)), [NpmScopeAttributes.INSTRUMENTED_RAW]: this.instrumentedPackage.name, [NpmScopeAttributes.INSTRUMENTED_OWNER]: instrumentedOwner, [NpmScopeAttributes.INSTRUMENTED_NAME]: instrumentedName, @@ -99,14 +128,6 @@ export class FunctionMetric extends ScopeMetric { [NpmScopeAttributes.INSTRUMENTED_VERSION_PRE_RELEASE]: instrumentedPreRelease?.join('.') } - // Handle renamed functions - if (this.matchingImport.rename !== undefined) { - metricData[JsScopeAttributes.FUNCTION_NAME] = this.jsFunction.name.replace( - this.matchingImport.rename, - this.matchingImport.name - ) - } - metricData = hash(metricData, [ NpmScopeAttributes.INSTRUMENTED_RAW, NpmScopeAttributes.INSTRUMENTED_OWNER, diff --git a/src/main/scopes/js/metrics/token-metric.ts b/src/main/scopes/js/metrics/token-metric.ts index 6c4cb193..fe79fa28 100644 --- a/src/main/scopes/js/metrics/token-metric.ts +++ b/src/main/scopes/js/metrics/token-metric.ts @@ -8,10 +8,14 @@ import { JsScopeAttributes, NpmScopeAttributes } from '@ibm/telemetry-attributes import type { Attributes } from '@opentelemetry/api' import { hash } from '../../../core/anonymize/hash.js' +import { substituteArray } from '../../../core/anonymize/substitute-array.js' +import { Substitution } from '../../../core/anonymize/substitution.js' import type { Logger } from '../../../core/log/logger.js' +import { safeStringify } from '../../../core/log/safe-stringify.js' import { PackageDetailsProvider } from '../../../core/package-details-provider.js' import { ScopeMetric } from '../../../core/scope-metric.js' import type { PackageData } from '../../npm/interfaces.js' +import { ComplexValue } from '../complex-value.js' import type { JsImport, JsToken } from '../interfaces.js' /** @@ -63,9 +67,46 @@ export class TokenMetric extends ScopeMetric { this.instrumentedPackage.version ) + let anonymizedTokenName = safeStringify(this.jsToken.name) + const anonymizedTokenAccessPath = [...this.jsToken.accessPath] + + // Handle renamed tokens + if (this.matchingImport.rename !== undefined) { + anonymizedTokenName = anonymizedTokenName.replace( + this.matchingImport.rename, + this.matchingImport.name + ) + // replace import name in access path + anonymizedTokenAccessPath[0] = this.matchingImport.name + } + + const subs = new Substitution() + // redact complex values + anonymizedTokenAccessPath.forEach((segment) => { + if (segment instanceof ComplexValue) { + anonymizedTokenName = anonymizedTokenName.replace( + safeStringify(segment.complexValue), + subs.put(segment) + ) + } + }) + + // redact "all" import + if (this.matchingImport.isAll) { + anonymizedTokenAccessPath[0] = subs.put(this.matchingImport.name) + anonymizedTokenName = anonymizedTokenName.replace( + this.matchingImport.name, + subs.put(this.matchingImport.name) + ) + } + let metricData: Attributes = { - [JsScopeAttributes.TOKEN_NAME]: this.jsToken.name, - [JsScopeAttributes.TOKEN_ACCESS_PATH]: this.jsToken.accessPath, + [JsScopeAttributes.TOKEN_NAME]: anonymizedTokenName, + [JsScopeAttributes.TOKEN_MODULE_SPECIFIER]: this.matchingImport.path, + [JsScopeAttributes.TOKEN_ACCESS_PATH]: substituteArray( + anonymizedTokenAccessPath, + anonymizedTokenAccessPath.filter((p) => typeof p === 'string') + ).join(' '), [NpmScopeAttributes.INSTRUMENTED_RAW]: this.instrumentedPackage.name, [NpmScopeAttributes.INSTRUMENTED_OWNER]: instrumentedOwner, [NpmScopeAttributes.INSTRUMENTED_NAME]: instrumentedName, @@ -76,14 +117,6 @@ export class TokenMetric extends ScopeMetric { [NpmScopeAttributes.INSTRUMENTED_VERSION_PRE_RELEASE]: instrumentedPreRelease?.join('.') } - // Handle renamed tokens - if (this.matchingImport.rename !== undefined) { - metricData[JsScopeAttributes.TOKEN_NAME] = this.jsToken.name.replace( - this.matchingImport.rename, - this.matchingImport.name - ) - } - metricData = hash(metricData, [ NpmScopeAttributes.INSTRUMENTED_RAW, NpmScopeAttributes.INSTRUMENTED_OWNER, diff --git a/src/main/scopes/js/node-handlers/tokens-and-functions-handlers/access-expression-node-handler.ts b/src/main/scopes/js/node-handlers/tokens-and-functions-handlers/access-expression-node-handler.ts new file mode 100644 index 00000000..cecb6416 --- /dev/null +++ b/src/main/scopes/js/node-handlers/tokens-and-functions-handlers/access-expression-node-handler.ts @@ -0,0 +1,70 @@ +/* + * Copyright IBM Corp. 2024, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import * as ts from 'typescript' + +import getAccessPath from '../../get-access-path.js' +import type { JsToken } from '../../interfaces.js' +import type { JsFunctionTokenAccumulator } from '../../js-function-token-accumulator.js' +import { JsNodeHandler } from '../js-node-handler.js' + +/** + * Holds logic to construct a JsToken object given an ElementAccessExpression or + * PropertyAccessExpression node. + * + */ +export class AccessExpressionNodeHandler extends JsNodeHandler { + /** + * Processes a node data and adds it to the given accumulator. + * + * @param node - Node element to process. + * @param accumulator - JsFunctionTokenAccumulator instance + * that holds the aggregated tokens state. + */ + handle( + node: ts.ElementAccessExpression | ts.PropertyAccessExpression, + accumulator: JsFunctionTokenAccumulator + ) { + // The logic below does the following: + // foo['bla']['cool'] <-- capture foo,blah,cool + // foo[BLA['cool']] <-- capture BLA,cool NOT foo,ComplexValue + // thing[one.two] <-- capture one,two NOT thing,ComplexValue + // thing.first['second'] <-- capture thing,first,second NOT thing,first + if ( + node.parent.kind === ts.SyntaxKind.ElementAccessExpression && + (node.parent as ts.ElementAccessExpression).argumentExpression !== node + ) { + return + } + + // expression is part of a larger property access or function call + if ( + [ts.SyntaxKind.PropertyAccessExpression, ts.SyntaxKind.CallExpression].includes( + node.parent.kind + ) + ) { + return + } + + accumulator.tokens.push(this.getData(node)) + } + + /** + * Constructs a JsToken object from a given ElementAccessExpression or PropertyAccessExpression + * type AST node. + * + * @param node - Node element to process. + * @returns Constructed JsToken object. + */ + getData(node: ts.ElementAccessExpression | ts.PropertyAccessExpression): JsToken { + return { + name: node.getText(this.sourceFile), + accessPath: getAccessPath(node, this.sourceFile, this.logger), + startPos: node.pos, + endPos: node.end + } + } +} diff --git a/src/main/scopes/js/node-handlers/tokens-and-functions-handlers/call-expression-node-handler.ts b/src/main/scopes/js/node-handlers/tokens-and-functions-handlers/call-expression-node-handler.ts index a46797d1..1800f9e7 100644 --- a/src/main/scopes/js/node-handlers/tokens-and-functions-handlers/call-expression-node-handler.ts +++ b/src/main/scopes/js/node-handlers/tokens-and-functions-handlers/call-expression-node-handler.ts @@ -6,8 +6,10 @@ */ import type * as ts from 'typescript' +import getAccessPath from '../../get-access-path.js' import type { JsFunction } from '../../interfaces.js' import type { JsFunctionTokenAccumulator } from '../../js-function-token-accumulator.js' +import { getNodeValueHandler } from '../../node-value-handler-map.js' import { JsNodeHandler } from '../js-node-handler.js' /** @@ -23,21 +25,35 @@ export class CallExpressionNodeHandler extends JsNodeHandler { * that holds the aggregated functions state. */ handle(node: ts.CallExpression, accumulator: JsFunctionTokenAccumulator) { - accumulator.functions.push(this.getData(node)) + const jsFunction = this.getData(node) + + accumulator.functions.push(jsFunction) } /** * Constructs a JsFunction object from a given CallExpression type AST node. * - * @param _node - Node element to process. + * @param node - Node element to process. * @returns Constructed JsFunction object. */ - getData(_node: ts.CallExpression): JsFunction { - // TODO: implement - return { - name: 'dummyFunction', - accessPath: 'dummyAccess', - arguments: [] + getData(node: ts.CallExpression): JsFunction { + const argsLength = node.arguments.end - node.arguments.pos + const nodeText = node.getText(this.sourceFile) + const jsFunction: JsFunction = { + // account for parentheses + name: nodeText.substring(0, nodeText.length - argsLength - 2), + accessPath: [], + arguments: [], + startPos: node.pos, + endPos: node.end } + + jsFunction.arguments = node.arguments.map((arg) => + getNodeValueHandler(arg.kind, this.sourceFile, this.logger).getData(arg) + ) + + jsFunction.accessPath = getAccessPath(node, this.sourceFile, this.logger) + + return jsFunction } } diff --git a/src/main/scopes/js/node-handlers/tokens-and-functions-handlers/element-access-expression-node-handler.ts b/src/main/scopes/js/node-handlers/tokens-and-functions-handlers/element-access-expression-node-handler.ts deleted file mode 100644 index 57832517..00000000 --- a/src/main/scopes/js/node-handlers/tokens-and-functions-handlers/element-access-expression-node-handler.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright IBM Corp. 2024, 2024 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ -import type * as ts from 'typescript' - -import type { JsToken } from '../../interfaces.js' -import type { JsFunctionTokenAccumulator } from '../../js-function-token-accumulator.js' -import { JsNodeHandler } from '../js-node-handler.js' - -/** - * Holds logic to construct a JsToken object given an ElementAccessExpression node. - * - */ -export class ElementAccessExpressionNodeHandler extends JsNodeHandler { - /** - * Processes a ElementAccessExpression node data and adds it to the given accumulator. - * - * @param node - Node element to process. - * @param accumulator - JsFunctionTokenAccumulator instance - * that holds the aggregated tokens state. - */ - handle(node: ts.ElementAccessExpression, accumulator: JsFunctionTokenAccumulator) { - accumulator.tokens.push(this.getData(node)) - } - - /** - * Constructs a JsToken object from a given ElementAccessExpression type AST node. - * - * @param _node - Node element to process. - * @returns Constructed JsToken object. - */ - getData(_node: ts.ElementAccessExpression): JsToken { - // TODO: implement - return { - name: 'dummyToken', - accessPath: 'dummyAccess' - } - } -} diff --git a/src/main/scopes/js/node-handlers/tokens-and-functions-handlers/identifier-node-handler.ts b/src/main/scopes/js/node-handlers/tokens-and-functions-handlers/identifier-node-handler.ts index 0dbadd60..f07f2449 100644 --- a/src/main/scopes/js/node-handlers/tokens-and-functions-handlers/identifier-node-handler.ts +++ b/src/main/scopes/js/node-handlers/tokens-and-functions-handlers/identifier-node-handler.ts @@ -4,7 +4,7 @@ * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ -import type * as ts from 'typescript' +import * as ts from 'typescript' import type { JsToken } from '../../interfaces.js' import type { JsFunctionTokenAccumulator } from '../../js-function-token-accumulator.js' @@ -23,21 +23,49 @@ export class IdentifierNodeHandler extends JsNodeHandler { * that holds the aggregated tokens state. */ handle(node: ts.Identifier, accumulator: JsFunctionTokenAccumulator) { + // The logic below does the following: + // foo[TOKEN] <-- capture TOKEN, not foo + // foo[BLA['cool']] <-- capture nothing + // thing[one.two] <-- capture nothing + // thing.first['second'] <-- capture nothing + if ( + node.parent.kind === ts.SyntaxKind.ElementAccessExpression && + (node.parent as ts.ElementAccessExpression).argumentExpression !== node + ) { + return + } + + if ( + [ + ts.SyntaxKind.JsxOpeningElement, + ts.SyntaxKind.JsxSelfClosingElement, + ts.SyntaxKind.JsxClosingElement, + ts.SyntaxKind.PropertyAccessExpression, + ts.SyntaxKind.CallExpression, + ts.SyntaxKind.ImportClause, + ts.SyntaxKind.ImportDeclaration, + ts.SyntaxKind.ImportSpecifier + ].includes(node.parent.kind) || + (node.parent.kind === ts.SyntaxKind.VariableDeclaration && + (node.parent as ts.VariableDeclaration).name === node) + ) { + return + } accumulator.tokens.push(this.getData(node)) } /** * Constructs a JsToken object from a given Identifier type AST node. * - * @param _node - Node element to process. + * @param node - Node element to process. * @returns Constructed JsToken object. */ - getData(_node: ts.Identifier): JsToken { - // TODO: implement, how to know this is not a part of a - // CallExpression, PropertyAccessExpression or ElementAccessExpression? + getData(node: ts.Identifier): JsToken { return { - name: 'dummyToken', - accessPath: 'dummyAccess' + name: node.escapedText.toString(), + accessPath: [node.escapedText.toString()], + startPos: node.pos, + endPos: node.end } } } diff --git a/src/main/scopes/js/node-handlers/tokens-and-functions-handlers/property-access-expression-node-handler.ts b/src/main/scopes/js/node-handlers/tokens-and-functions-handlers/property-access-expression-node-handler.ts deleted file mode 100644 index a7b8f870..00000000 --- a/src/main/scopes/js/node-handlers/tokens-and-functions-handlers/property-access-expression-node-handler.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright IBM Corp. 2024, 2024 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ -import type * as ts from 'typescript' - -import type { JsToken } from '../../interfaces.js' -import type { JsFunctionTokenAccumulator } from '../../js-function-token-accumulator.js' -import { JsNodeHandler } from '../js-node-handler.js' - -/** - * Holds logic to construct a JsToken object given an PropertyAccessExpression node. - * - */ -export class PropertyAccessExpressionNodeHandler extends JsNodeHandler { - /** - * Processes a PropertyAccessExpression node data and adds it to the given accumulator. - * - * @param node - Node element to process. - * @param accumulator - JsFunctionTokenAccumulator instance - * that holds the aggregated tokens state. - */ - handle(node: ts.PropertyAccessExpression, accumulator: JsFunctionTokenAccumulator) { - accumulator.tokens.push(this.getData(node)) - } - - /** - * Constructs a JsToken object from a given PropertyAccessExpression type AST node. - * - * @param _node - Node element to process. - * @returns Constructed JsToken object. - */ - getData(_node: ts.PropertyAccessExpression): JsToken { - // TODO: implement - return { - name: 'dummyToken', - accessPath: 'dummyAccess' - } - } -} diff --git a/src/main/scopes/js/remove-irrelevant-imports.ts b/src/main/scopes/js/remove-irrelevant-imports.ts index 58dc1a3f..98932e02 100644 --- a/src/main/scopes/js/remove-irrelevant-imports.ts +++ b/src/main/scopes/js/remove-irrelevant-imports.ts @@ -14,9 +14,7 @@ import type { JsAccumulator } from './js-accumulator.js' * @param packageName - Name of the package to filter imports for. */ export function removeIrrelevantImports(accumulator: JsAccumulator, packageName: string) { - const imports = accumulator.imports.filter((jsImport) => { + accumulator.imports = accumulator.imports.filter((jsImport) => { return jsImport.path === packageName || jsImport.path.startsWith(`${packageName}/`) }) - - accumulator.imports.splice(0, accumulator.imports.length, ...imports) } diff --git a/src/main/scopes/jsx/metrics/element-metric.ts b/src/main/scopes/jsx/metrics/element-metric.ts index 9833e6cd..646c4139 100644 --- a/src/main/scopes/jsx/metrics/element-metric.ts +++ b/src/main/scopes/jsx/metrics/element-metric.ts @@ -9,7 +9,7 @@ import { type ConfigSchema } from '@ibm/telemetry-config-schema' import type { Attributes } from '@opentelemetry/api' import { hash } from '../../../core/anonymize/hash.js' -import { substitute } from '../../../core/anonymize/substitute.js' +import { substituteObject } from '../../../core/anonymize/substitute-object.js' import { deNull } from '../../../core/de-null.js' import { type Logger } from '../../../core/log/logger.js' import { PackageDetailsProvider } from '../../../core/package-details-provider.js' @@ -70,7 +70,7 @@ export class ElementMetric extends ScopeMetric { ) ) - const anonymizedAttributes = substitute( + const anonymizedAttributes = substituteObject( attrMap, this.allowedAttributeNames, this.allowedAttributeStringValues diff --git a/src/main/scopes/npm/get-package-data.ts b/src/main/scopes/npm/get-package-data.ts index 19dfbee2..6052a0c5 100644 --- a/src/main/scopes/npm/get-package-data.ts +++ b/src/main/scopes/npm/get-package-data.ts @@ -37,10 +37,13 @@ export async function getPackageData( for (const dir of dirs) { try { packageData = await getImmediatePackageData(dir, logger) - break } catch (err) { logger.debug(String(err)) } + + if (packageData?.name !== undefined && packageData?.version !== undefined) { + break + } } if (packageData === undefined) { diff --git a/src/test/__fixtures/projects/basic-project/test.js b/src/test/__fixtures/projects/basic-project/test.js index ef18cfa4..d306db77 100644 --- a/src/test/__fixtures/projects/basic-project/test.js +++ b/src/test/__fixtures/projects/basic-project/test.js @@ -1,2 +1,9 @@ // @ts-nocheck +import * as BLA from 'instrumented/the/path' + +const A = BLA['hi'] + +BLA.someFunction(1,2,3, [4,5,6], true, false, 32, {an: 'object'}) + +one.two.three['four'] diff --git a/src/test/__fixtures/projects/basic-project/test.jsx b/src/test/__fixtures/projects/basic-project/test.jsx index ca6ca626..712acc93 100644 --- a/src/test/__fixtures/projects/basic-project/test.jsx +++ b/src/test/__fixtures/projects/basic-project/test.jsx @@ -1,5 +1,6 @@ // @ts-nocheck import Button from 'instrumented' +import BLE from 'instrumented' const sample = const secondSample = @@ -8,3 +9,7 @@ export { sample, secondSample } + +anObject[BLE.property]['anotherProperty'] + +BLE.property.aFunction?.('click', aFunction) diff --git a/src/test/__fixtures/projects/basic-project/test.ts b/src/test/__fixtures/projects/basic-project/test.ts index 9b08445e..6509c5b5 100644 --- a/src/test/__fixtures/projects/basic-project/test.ts +++ b/src/test/__fixtures/projects/basic-project/test.ts @@ -4,6 +4,11 @@ * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ +// @ts-nocheck +import * as BLA from 'not/instrumented' + export function unrelated() { return 'hello world' } + +BLA.functionCall() diff --git a/src/test/__fixtures/projects/basic-project/test.tsx b/src/test/__fixtures/projects/basic-project/test.tsx index 8cebf3b8..459d33da 100644 --- a/src/test/__fixtures/projects/basic-project/test.tsx +++ b/src/test/__fixtures/projects/basic-project/test.tsx @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ // @ts-nocheck -import { ImaginaryThing } from 'instrumented' +import { ImaginaryThing, Function1, A_TOKEN as A_RENAMED_TOKEN, anObject } from 'instrumented' import OtherThing from '@not/instrumented' export const MyComponent = ({other, ...spreadObj}) => { @@ -27,3 +27,7 @@ export const MyComponent = ({other, ...spreadObj}) => { ) } + +anObject['property'][A_RENAMED_TOKEN]('hey', 500, {yeah: 'yeah'}) + +Function1() diff --git a/src/test/__fixtures/projects/empty-package-json/inner-folder/package.json b/src/test/__fixtures/projects/empty-package-json/inner-folder/package.json new file mode 100644 index 00000000..5bbefffb --- /dev/null +++ b/src/test/__fixtures/projects/empty-package-json/inner-folder/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/src/test/__fixtures/projects/empty-package-json/inner-folder/test.js b/src/test/__fixtures/projects/empty-package-json/inner-folder/test.js new file mode 100644 index 00000000..ef18cfa4 --- /dev/null +++ b/src/test/__fixtures/projects/empty-package-json/inner-folder/test.js @@ -0,0 +1,2 @@ + +// @ts-nocheck diff --git a/src/test/__fixtures/projects/empty-package-json/node_modules/foo/package.json b/src/test/__fixtures/projects/empty-package-json/node_modules/foo/package.json new file mode 100644 index 00000000..01df3d32 --- /dev/null +++ b/src/test/__fixtures/projects/empty-package-json/node_modules/foo/package.json @@ -0,0 +1,12 @@ +{ + "name": "foo", + "description": "", + "version": "1.0.0", + "license": "Apache-2.0", + "author": "IBM", + "keywords": [], + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/src/test/__fixtures/projects/empty-package-json/node_modules/instrumented/package.json b/src/test/__fixtures/projects/empty-package-json/node_modules/instrumented/package.json new file mode 100644 index 00000000..c9928017 --- /dev/null +++ b/src/test/__fixtures/projects/empty-package-json/node_modules/instrumented/package.json @@ -0,0 +1,12 @@ +{ + "name": "instrumented", + "description": "", + "version": "0.1.0", + "license": "Apache-2.0", + "author": "IBM", + "keywords": [], + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/src/test/__fixtures/projects/empty-package-json/package.json b/src/test/__fixtures/projects/empty-package-json/package.json new file mode 100644 index 00000000..7c6007c8 --- /dev/null +++ b/src/test/__fixtures/projects/empty-package-json/package.json @@ -0,0 +1,16 @@ +{ + "name": "basic-project", + "description": "", + "version": "1.0.0", + "license": "Apache-2.0", + "author": "IBM", + "keywords": [], + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "foo": "^1.0.0", + "instrumented": "^0.1.0" + } +} diff --git a/src/test/__fixtures/projects/hoisted-deeply-nested-deps/test.mts b/src/test/__fixtures/projects/hoisted-deeply-nested-deps/test.mts new file mode 100644 index 00000000..624d84c4 --- /dev/null +++ b/src/test/__fixtures/projects/hoisted-deeply-nested-deps/test.mts @@ -0,0 +1,6 @@ +// @ts-nocheck +import { TEST, somefunction } from 'instrumented' + +TEST[TEST[one['two']]].prop.property + +somefunction(1, [2,3,4], 'firstArg', true) \ No newline at end of file diff --git a/src/test/__fixtures/projects/multiple-versions-of-instrumented-dep/a/test.js b/src/test/__fixtures/projects/multiple-versions-of-instrumented-dep/a/test.js new file mode 100644 index 00000000..f7ecc258 --- /dev/null +++ b/src/test/__fixtures/projects/multiple-versions-of-instrumented-dep/a/test.js @@ -0,0 +1,6 @@ +// @ts-nocheck +import { TOKEN } from 'instrumented' + +BLA = TOKEN['60'] + +TOKEN.function().anotherFunction('firstArg', 'secondArg') \ No newline at end of file diff --git a/src/test/__fixtures/projects/multiple-versions-of-instrumented-dep/b/some-folder/test.js b/src/test/__fixtures/projects/multiple-versions-of-instrumented-dep/b/some-folder/test.js new file mode 100644 index 00000000..fab6c442 --- /dev/null +++ b/src/test/__fixtures/projects/multiple-versions-of-instrumented-dep/b/some-folder/test.js @@ -0,0 +1,6 @@ +// @ts-nocheck +import * as util from 'instrumented' + +BLA = util.one.two['three'] + +util['one'].two[BLA['bla']].function(1,2,3,4) \ No newline at end of file diff --git a/src/test/__fixtures/projects/multiple-versions-of-instrumented-dep/b/test.js b/src/test/__fixtures/projects/multiple-versions-of-instrumented-dep/b/test.js new file mode 100644 index 00000000..e56e4fa3 --- /dev/null +++ b/src/test/__fixtures/projects/multiple-versions-of-instrumented-dep/b/test.js @@ -0,0 +1,6 @@ +// @ts-nocheck +import TOKEN, { aFunction } from 'instrumented' + +BLA = TOKEN + +aFunction() \ No newline at end of file diff --git a/src/test/__fixtures/projects/workspace-files-governed-by-root-dep/package1/test.mtsx b/src/test/__fixtures/projects/workspace-files-governed-by-root-dep/package1/test.mtsx new file mode 100644 index 00000000..6c0c1e24 --- /dev/null +++ b/src/test/__fixtures/projects/workspace-files-governed-by-root-dep/package1/test.mtsx @@ -0,0 +1,10 @@ +// @ts-nocheck +import * as instrumented from 'instrumented-top-level' + +import BLA from 'another-package' + +const ble = BLA[instrumented['hii']] + +instrumented.callingAFunction('firstArg') + + diff --git a/src/test/__utils/create-source-file-from-text.ts b/src/test/__utils/create-source-file-from-text.ts new file mode 100644 index 00000000..79ad44ee --- /dev/null +++ b/src/test/__utils/create-source-file-from-text.ts @@ -0,0 +1,22 @@ +/* + * Copyright IBM Corp. 2024, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import * as ts from 'typescript' + +/** + * Given some input text, creates a ts source file with a dummy name. + * + * @param src - The string to be converted into to a source file. + * @returns A ts source file. + */ +export function createSourceFileFromText(src: string) { + return ts.createSourceFile( + 'inline-test-file.ts', + src, + ts.ScriptTarget.ES2021, + /* setParentNodes */ true + ) +} diff --git a/src/test/core/anonymize/substitute-array.test.ts b/src/test/core/anonymize/substitute-array.test.ts new file mode 100644 index 00000000..53166b63 --- /dev/null +++ b/src/test/core/anonymize/substitute-array.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright IBM Corp. 2024, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import { describe, expect, it } from 'vitest' + +import { substituteArray } from '../../../main/core/anonymize/substitute-array.js' + +describe('substituteArray', () => { + it('correctly anonymizes sensitive data', () => { + const arr = ['sensitive value'] + const anonymized = substituteArray(arr, []) + + expect(anonymized).not.toContain('sensitive value') + }) + + it('does not anonymize an allowed value', () => { + const arr = ['allowed value'] + + const anonymized = substituteArray(arr, ['allowed value']) + + expect(anonymized).toContain('allowed value') + }) + + it('reuses a substitution value that appears more than once', () => { + const arr1 = ['sensitive value'] + const arr2 = ['sensitive value'] + + const anon1 = substituteArray(arr1, []) + const anon2 = substituteArray(arr2, []) + + expect(anon1).not.toContain('sensitive value') + expect(anon1).toStrictEqual(anon2) + }) + + it('does not anonymize a number', () => { + const arr = [Number('123')] + const anon = substituteArray(arr, []) + expect(anon).toStrictEqual(arr) + }) + + it('does not anonymize a boolean', () => { + const arr = [true] + const anon = substituteArray(arr, []) + expect(anon).toStrictEqual(arr) + }) + + it('does not anonymize a null', () => { + const arr = [null] + const anon = substituteArray(arr, []) + expect(anon).toStrictEqual(arr) + }) + + it('does not anonymize a null', () => { + const arr = [undefined] + const anon = substituteArray(arr, []) + expect(anon).toStrictEqual(arr) + }) +}) diff --git a/src/test/core/anonymize/substitute.test.ts b/src/test/core/anonymize/substitute-object.test.ts similarity index 72% rename from src/test/core/anonymize/substitute.test.ts rename to src/test/core/anonymize/substitute-object.test.ts index 0eca16cf..4ad4eb8c 100644 --- a/src/test/core/anonymize/substitute.test.ts +++ b/src/test/core/anonymize/substitute-object.test.ts @@ -6,14 +6,14 @@ */ import { describe, expect, it } from 'vitest' -import { substitute } from '../../../main/core/anonymize/substitute.js' +import { substituteObject } from '../../../main/core/anonymize/substitute-object.js' -describe('substitute', () => { +describe('substituteObject', () => { it('correctly anonymizes sensitive data', () => { const obj = { sensitiveKey: 'sensitive value' } - const anonymized = substitute(obj, [], []) + const anonymized = substituteObject(obj, [], []) expect(anonymized.sensitiveKey).toBeUndefined() expect(Object.values(anonymized)).not.toContain('sensitive value') @@ -23,7 +23,7 @@ describe('substitute', () => { const obj = { knownKey: 'cool sensitive value' } - const anonymized = substitute(obj, ['knownKey'], []) + const anonymized = substituteObject(obj, ['knownKey'], []) expect(anonymized.knownKey).not.toBe('cool sensitive value') }) @@ -32,7 +32,7 @@ describe('substitute', () => { const obj = { knownKey: 'known value' } - const anonymized = substitute(obj, ['knownKey'], ['known value']) + const anonymized = substituteObject(obj, ['knownKey'], ['known value']) expect(anonymized).toMatchObject({ knownKey: 'known value' @@ -47,8 +47,8 @@ describe('substitute', () => { sensitiveKey: 'sensitive value' } - const anon1 = substitute(obj1, [], []) - const anon2 = substitute(obj2, [], []) + const anon1 = substituteObject(obj1, [], []) + const anon2 = substituteObject(obj2, [], []) expect(Object.keys(anon1)).toStrictEqual(Object.keys(anon2)) expect(Object.values(anon1)).toStrictEqual(Object.values(anon2)) @@ -59,8 +59,8 @@ describe('substitute', () => { knownKey: { some: 'object' } } - substitute(obj, [], []) - const anonymized = substitute(obj, ['knownKey'], []) + substituteObject(obj, [], []) + const anonymized = substituteObject(obj, ['knownKey'], []) expect(anonymized).toMatchObject({ knownKey: '[redacted5]' @@ -75,8 +75,8 @@ describe('substitute', () => { knownKey: { some: 'object' } } - const anon1 = substitute(obj1, ['knownKey'], []) - const anon2 = substitute(obj2, ['knownKey'], []) + const anon1 = substituteObject(obj1, ['knownKey'], []) + const anon2 = substituteObject(obj2, ['knownKey'], []) expect(anon1.knownKey).toStrictEqual(anon2.knownKey) }) @@ -87,7 +87,7 @@ describe('substitute', () => { knownKey2: "{ some: 'object' }" } - const anon = substitute(obj, ['knownKey1', 'knownKey2'], []) + const anon = substituteObject(obj, ['knownKey1', 'knownKey2'], []) expect(anon.knownKey1).not.toBe(anon.knownKey2) }) @@ -96,7 +96,7 @@ describe('substitute', () => { const obj = { knownKey1: Number('123') } - const anon = substitute(obj, ['knownKey1'], []) + const anon = substituteObject(obj, ['knownKey1'], []) expect(anon.knownKey1).toBe(obj.knownKey1) }) @@ -104,7 +104,7 @@ describe('substitute', () => { const obj = { knownKey1: true } - const anon = substitute(obj, ['knownKey1'], []) + const anon = substituteObject(obj, ['knownKey1'], []) expect(anon.knownKey1).toBe(obj.knownKey1) }) @@ -112,7 +112,7 @@ describe('substitute', () => { const obj = { knownKey1: null } - const anon = substitute(obj, ['knownKey1'], []) + const anon = substituteObject(obj, ['knownKey1'], []) expect(anon.knownKey1).toBe(obj.knownKey1) }) @@ -120,7 +120,7 @@ describe('substitute', () => { const obj = { knownKey1: undefined } - const anon = substitute(obj, ['knownKey1'], []) + const anon = substituteObject(obj, ['knownKey1'], []) expect(anon.knownKey1).toBe(obj.knownKey1) }) }) diff --git a/src/test/scopes/js/__snapshots__/js-scope.e2e.test.ts.snap b/src/test/scopes/js/__snapshots__/js-scope.e2e.test.ts.snap new file mode 100644 index 00000000..6e440ee2 --- /dev/null +++ b/src/test/scopes/js/__snapshots__/js-scope.e2e.test.ts.snap @@ -0,0 +1,549 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`class: JsScope > run > captures metrics for workspace files when instrumented package is installed by root package 1`] = ` +{ + "errors": [], + "resourceMetrics": { + "resource": Resource { + "_asyncAttributesPromise": undefined, + "_attributes": { + "analyzed.commit": "commitHash", + "analyzed.host": "host", + "analyzed.owner": "owner", + "analyzed.path": "host/owner/repository", + "analyzed.refs": [], + "analyzed.repository": "repository", + "date": "1970-01-01T00:00:02.023Z", + "project.id": "projectId", + "service.name": "IBM Telemetry", + "telemetry.emitter.name": "telemetryName", + "telemetry.emitter.version": "telemetryVersion", + "telemetry.sdk.language": "nodejs", + "telemetry.sdk.name": "opentelemetry", + "telemetry.sdk.version": "[omitted for test snapshot]", + }, + "_syncAttributes": { + "analyzed.commit": "commitHash", + "analyzed.host": "host", + "analyzed.owner": "owner", + "analyzed.path": "host/owner/repository", + "analyzed.refs": [], + "analyzed.repository": "repository", + "date": "1970-01-01T00:00:02.023Z", + "project.id": "projectId", + "service.name": "IBM Telemetry", + "telemetry.emitter.name": "telemetryName", + "telemetry.emitter.version": "telemetryVersion", + "telemetry.sdk.language": "nodejs", + "telemetry.sdk.name": "opentelemetry", + "telemetry.sdk.version": "[omitted for test snapshot]", + }, + "asyncAttributesPending": false, + }, + "scopeMetrics": [ + { + "metrics": [ + { + "aggregationTemporality": 1, + "dataPointType": 3, + "dataPoints": [ + { + "attributes": { + "js.token.accessPath": "[redacted14] hii", + "js.token.module.specifier": "instrumented-top-level", + "js.token.name": "[redacted14]['hii']", + "npm.dependency.instrumented.name": "460da73f7e63aeb72106788158261156d165376633de24e4a6673122ce2470aa", + "npm.dependency.instrumented.raw": "460da73f7e63aeb72106788158261156d165376633de24e4a6673122ce2470aa", + "npm.dependency.instrumented.version.major": "1", + "npm.dependency.instrumented.version.minor": "0", + "npm.dependency.instrumented.version.patch": "0", + "npm.dependency.instrumented.version.raw": "92521fc3cbd964bdc9f584a991b89fddaa5754ed1cc96d6d42445338669c1305", + }, + "endTime": [ + 0, + 0, + ], + "startTime": [ + 0, + 0, + ], + "value": 1, + }, + ], + "descriptor": { + "advice": {}, + "description": "", + "name": "js.token", + "type": "COUNTER", + "unit": "", + "valueType": 0, + }, + "isMonotonic": true, + }, + { + "aggregationTemporality": 1, + "dataPointType": 3, + "dataPoints": [ + { + "attributes": { + "js.function.accessPath": "[redacted14] callingAFunction", + "js.function.arguments.values": [ + "firstArg", + ], + "js.function.module.specifier": "instrumented-top-level", + "js.function.name": "[redacted14].callingAFunction", + "npm.dependency.instrumented.name": "460da73f7e63aeb72106788158261156d165376633de24e4a6673122ce2470aa", + "npm.dependency.instrumented.raw": "460da73f7e63aeb72106788158261156d165376633de24e4a6673122ce2470aa", + "npm.dependency.instrumented.version.major": "1", + "npm.dependency.instrumented.version.minor": "0", + "npm.dependency.instrumented.version.patch": "0", + "npm.dependency.instrumented.version.raw": "92521fc3cbd964bdc9f584a991b89fddaa5754ed1cc96d6d42445338669c1305", + }, + "endTime": [ + 0, + 0, + ], + "startTime": [ + 0, + 0, + ], + "value": 1, + }, + ], + "descriptor": { + "advice": {}, + "description": "", + "name": "js.function", + "type": "COUNTER", + "unit": "", + "valueType": 0, + }, + "isMonotonic": true, + }, + ], + "scope": { + "name": "js", + "schemaUrl": undefined, + "version": "", + }, + }, + ], + }, +} +`; + +exports[`class: JsScope > run > captures metrics when instrumented package is installed in intermediate package 1`] = ` +{ + "errors": [], + "resourceMetrics": { + "resource": Resource { + "_asyncAttributesPromise": undefined, + "_attributes": { + "analyzed.commit": "commitHash", + "analyzed.host": "host", + "analyzed.owner": "owner", + "analyzed.path": "host/owner/repository", + "analyzed.refs": [], + "analyzed.repository": "repository", + "date": "1970-01-01T00:00:02.023Z", + "project.id": "projectId", + "service.name": "IBM Telemetry", + "telemetry.emitter.name": "telemetryName", + "telemetry.emitter.version": "telemetryVersion", + "telemetry.sdk.language": "nodejs", + "telemetry.sdk.name": "opentelemetry", + "telemetry.sdk.version": "[omitted for test snapshot]", + }, + "_syncAttributes": { + "analyzed.commit": "commitHash", + "analyzed.host": "host", + "analyzed.owner": "owner", + "analyzed.path": "host/owner/repository", + "analyzed.refs": [], + "analyzed.repository": "repository", + "date": "1970-01-01T00:00:02.023Z", + "project.id": "projectId", + "service.name": "IBM Telemetry", + "telemetry.emitter.name": "telemetryName", + "telemetry.emitter.version": "telemetryVersion", + "telemetry.sdk.language": "nodejs", + "telemetry.sdk.name": "opentelemetry", + "telemetry.sdk.version": "[omitted for test snapshot]", + }, + "asyncAttributesPending": false, + }, + "scopeMetrics": [ + { + "metrics": [ + { + "aggregationTemporality": 1, + "dataPointType": 3, + "dataPoints": [ + { + "attributes": { + "js.token.accessPath": "TEST [redacted11] prop property", + "js.token.module.specifier": "instrumented", + "js.token.name": "TEST[[redacted11]].prop.property", + "npm.dependency.instrumented.name": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", + "npm.dependency.instrumented.raw": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", + "npm.dependency.instrumented.version.major": "1", + "npm.dependency.instrumented.version.minor": "0", + "npm.dependency.instrumented.version.patch": "0", + "npm.dependency.instrumented.version.raw": "92521fc3cbd964bdc9f584a991b89fddaa5754ed1cc96d6d42445338669c1305", + }, + "endTime": [ + 0, + 0, + ], + "startTime": [ + 0, + 0, + ], + "value": 1, + }, + { + "attributes": { + "js.token.accessPath": "TEST [redacted12]", + "js.token.module.specifier": "instrumented", + "js.token.name": "TEST[[redacted12]]", + "npm.dependency.instrumented.name": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", + "npm.dependency.instrumented.raw": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", + "npm.dependency.instrumented.version.major": "1", + "npm.dependency.instrumented.version.minor": "0", + "npm.dependency.instrumented.version.patch": "0", + "npm.dependency.instrumented.version.raw": "92521fc3cbd964bdc9f584a991b89fddaa5754ed1cc96d6d42445338669c1305", + }, + "endTime": [ + 0, + 0, + ], + "startTime": [ + 0, + 0, + ], + "value": 1, + }, + ], + "descriptor": { + "advice": {}, + "description": "", + "name": "js.token", + "type": "COUNTER", + "unit": "", + "valueType": 0, + }, + "isMonotonic": true, + }, + { + "aggregationTemporality": 1, + "dataPointType": 3, + "dataPoints": [ + { + "attributes": { + "js.function.accessPath": "somefunction", + "js.function.arguments.values": [ + "1", + "[redacted13]", + "firstArg", + "true", + ], + "js.function.module.specifier": "instrumented", + "js.function.name": "somefunction", + "npm.dependency.instrumented.name": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", + "npm.dependency.instrumented.raw": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", + "npm.dependency.instrumented.version.major": "1", + "npm.dependency.instrumented.version.minor": "0", + "npm.dependency.instrumented.version.patch": "0", + "npm.dependency.instrumented.version.raw": "92521fc3cbd964bdc9f584a991b89fddaa5754ed1cc96d6d42445338669c1305", + }, + "endTime": [ + 0, + 0, + ], + "startTime": [ + 0, + 0, + ], + "value": 1, + }, + ], + "descriptor": { + "advice": {}, + "description": "", + "name": "js.function", + "type": "COUNTER", + "unit": "", + "valueType": 0, + }, + "isMonotonic": true, + }, + ], + "scope": { + "name": "js", + "schemaUrl": undefined, + "version": "", + }, + }, + ], + }, +} +`; + +exports[`class: JsScope > run > correctly captures js function and token metric data 1`] = ` +{ + "errors": [], + "resourceMetrics": { + "resource": Resource { + "_asyncAttributesPromise": undefined, + "_attributes": { + "analyzed.commit": "commitHash", + "analyzed.host": "host", + "analyzed.owner": "owner", + "analyzed.path": "host/owner/repository", + "analyzed.refs": [], + "analyzed.repository": "repository", + "date": "1970-01-01T00:00:02.023Z", + "project.id": "projectId", + "service.name": "IBM Telemetry", + "telemetry.emitter.name": "telemetryName", + "telemetry.emitter.version": "telemetryVersion", + "telemetry.sdk.language": "nodejs", + "telemetry.sdk.name": "opentelemetry", + "telemetry.sdk.version": "[omitted for test snapshot]", + }, + "_syncAttributes": { + "analyzed.commit": "commitHash", + "analyzed.host": "host", + "analyzed.owner": "owner", + "analyzed.path": "host/owner/repository", + "analyzed.refs": [], + "analyzed.repository": "repository", + "date": "1970-01-01T00:00:02.023Z", + "project.id": "projectId", + "service.name": "IBM Telemetry", + "telemetry.emitter.name": "telemetryName", + "telemetry.emitter.version": "telemetryVersion", + "telemetry.sdk.language": "nodejs", + "telemetry.sdk.name": "opentelemetry", + "telemetry.sdk.version": "[omitted for test snapshot]", + }, + "asyncAttributesPending": false, + }, + "scopeMetrics": [ + { + "metrics": [ + { + "aggregationTemporality": 1, + "dataPointType": 3, + "dataPoints": [ + { + "attributes": { + "js.token.accessPath": "[redacted1] hi", + "js.token.module.specifier": "instrumented/the/path", + "js.token.name": "[redacted1]['hi']", + "npm.dependency.instrumented.name": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", + "npm.dependency.instrumented.raw": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", + "npm.dependency.instrumented.version.major": "0", + "npm.dependency.instrumented.version.minor": "1", + "npm.dependency.instrumented.version.patch": "0", + "npm.dependency.instrumented.version.raw": "6ad9613a455798d6d92e5f5f390ab4baa70596bc869ed6b17f5cdd2b28635f06", + }, + "endTime": [ + 0, + 0, + ], + "startTime": [ + 0, + 0, + ], + "value": 1, + }, + { + "attributes": { + "js.token.accessPath": "[Default] property", + "js.token.module.specifier": "instrumented", + "js.token.name": "[Default].property", + "npm.dependency.instrumented.name": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", + "npm.dependency.instrumented.raw": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", + "npm.dependency.instrumented.version.major": "0", + "npm.dependency.instrumented.version.minor": "1", + "npm.dependency.instrumented.version.patch": "0", + "npm.dependency.instrumented.version.raw": "6ad9613a455798d6d92e5f5f390ab4baa70596bc869ed6b17f5cdd2b28635f06", + }, + "endTime": [ + 0, + 0, + ], + "startTime": [ + 0, + 0, + ], + "value": 1, + }, + { + "attributes": { + "js.token.accessPath": "A_TOKEN", + "js.token.module.specifier": "instrumented", + "js.token.name": "A_TOKEN", + "npm.dependency.instrumented.name": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", + "npm.dependency.instrumented.raw": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", + "npm.dependency.instrumented.version.major": "0", + "npm.dependency.instrumented.version.minor": "1", + "npm.dependency.instrumented.version.patch": "0", + "npm.dependency.instrumented.version.raw": "6ad9613a455798d6d92e5f5f390ab4baa70596bc869ed6b17f5cdd2b28635f06", + }, + "endTime": [ + 0, + 0, + ], + "startTime": [ + 0, + 0, + ], + "value": 1, + }, + ], + "descriptor": { + "advice": {}, + "description": "", + "name": "js.token", + "type": "COUNTER", + "unit": "", + "valueType": 0, + }, + "isMonotonic": true, + }, + { + "aggregationTemporality": 1, + "dataPointType": 3, + "dataPoints": [ + { + "attributes": { + "js.function.accessPath": "[redacted1] someFunction", + "js.function.arguments.values": [ + "1", + "2", + "3", + "[redacted2]", + "true", + "false", + "32", + "[redacted3]", + ], + "js.function.module.specifier": "instrumented/the/path", + "js.function.name": "[redacted1].someFunction", + "npm.dependency.instrumented.name": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", + "npm.dependency.instrumented.raw": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", + "npm.dependency.instrumented.version.major": "0", + "npm.dependency.instrumented.version.minor": "1", + "npm.dependency.instrumented.version.patch": "0", + "npm.dependency.instrumented.version.raw": "6ad9613a455798d6d92e5f5f390ab4baa70596bc869ed6b17f5cdd2b28635f06", + }, + "endTime": [ + 0, + 0, + ], + "startTime": [ + 0, + 0, + ], + "value": 1, + }, + { + "attributes": { + "js.function.accessPath": "[Default] property aFunction", + "js.function.arguments.values": [ + "[redacted4]", + "[redacted5]", + ], + "js.function.module.specifier": "instrumented", + "js.function.name": "[Default].property.aFunction?.", + "npm.dependency.instrumented.name": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", + "npm.dependency.instrumented.raw": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", + "npm.dependency.instrumented.version.major": "0", + "npm.dependency.instrumented.version.minor": "1", + "npm.dependency.instrumented.version.patch": "0", + "npm.dependency.instrumented.version.raw": "6ad9613a455798d6d92e5f5f390ab4baa70596bc869ed6b17f5cdd2b28635f06", + }, + "endTime": [ + 0, + 0, + ], + "startTime": [ + 0, + 0, + ], + "value": 1, + }, + { + "attributes": { + "js.function.accessPath": "anObject property [redacted6]", + "js.function.arguments.values": [ + "[redacted7]", + "500", + "[redacted8]", + ], + "js.function.module.specifier": "instrumented", + "js.function.name": "anObject['property'][[redacted6]]", + "npm.dependency.instrumented.name": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", + "npm.dependency.instrumented.raw": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", + "npm.dependency.instrumented.version.major": "0", + "npm.dependency.instrumented.version.minor": "1", + "npm.dependency.instrumented.version.patch": "0", + "npm.dependency.instrumented.version.raw": "6ad9613a455798d6d92e5f5f390ab4baa70596bc869ed6b17f5cdd2b28635f06", + }, + "endTime": [ + 0, + 0, + ], + "startTime": [ + 0, + 0, + ], + "value": 1, + }, + { + "attributes": { + "js.function.accessPath": "Function1", + "js.function.arguments.values": [], + "js.function.module.specifier": "instrumented", + "js.function.name": "Function1", + "npm.dependency.instrumented.name": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", + "npm.dependency.instrumented.raw": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", + "npm.dependency.instrumented.version.major": "0", + "npm.dependency.instrumented.version.minor": "1", + "npm.dependency.instrumented.version.patch": "0", + "npm.dependency.instrumented.version.raw": "6ad9613a455798d6d92e5f5f390ab4baa70596bc869ed6b17f5cdd2b28635f06", + }, + "endTime": [ + 0, + 0, + ], + "startTime": [ + 0, + 0, + ], + "value": 1, + }, + ], + "descriptor": { + "advice": {}, + "description": "", + "name": "js.function", + "type": "COUNTER", + "unit": "", + "valueType": 0, + }, + "isMonotonic": true, + }, + ], + "scope": { + "name": "js", + "schemaUrl": undefined, + "version": "", + }, + }, + ], + }, +} +`; diff --git a/src/test/scopes/js/__snapshots__/process-file.test.ts.snap b/src/test/scopes/js/__snapshots__/process-file.test.ts.snap index b7b73183..b1c53610 100644 --- a/src/test/scopes/js/__snapshots__/process-file.test.ts.snap +++ b/src/test/scopes/js/__snapshots__/process-file.test.ts.snap @@ -54,5 +54,12 @@ exports[`processFile > correctly detects imports and elements in a given file > "path": "instrumented", "rename": "Button", }, + { + "isAll": false, + "isDefault": true, + "name": "[Default]", + "path": "instrumented", + "rename": "BLE", + }, ] `; diff --git a/src/test/scopes/js/find-relevant-source-files.test.ts b/src/test/scopes/js/find-relevant-source-files.test.ts new file mode 100644 index 00000000..78754457 --- /dev/null +++ b/src/test/scopes/js/find-relevant-source-files.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright IBM Corp. 2024, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import { describe, expect, it } from 'vitest' + +import { findRelevantSourceFiles } from '../../../main/scopes/js/find-relevant-source-files.js' +import { Fixture } from '../../__utils/fixture.js' +import { initLogger } from '../../__utils/init-logger.js' + +// only testing edge cases since this function is used pervasively across scopes +describe('findRelevantSourceFiles', () => { + const logger = initLogger() + + it('does not throw an error when no associated instrumented package was found', async () => { + const cwd = new Fixture('projects/basic-project/node_modules/foo') + const root = new Fixture('projects/basic-project') + + const relevantSourceFilesPromise = findRelevantSourceFiles( + { name: 'not-found', version: '1.2.3' }, + cwd.path, + root.path, + ['.js'], + logger + ) + + await expect(relevantSourceFilesPromise).resolves.toStrictEqual([]) + }) + + it('does not throw an error when empty package.json is encountered along the way', async () => { + const cwd = new Fixture('projects/empty-package-json/node_modules/foo') + const root = new Fixture('projects/empty-package-json') + + const relevantSourceFilesPromise = findRelevantSourceFiles( + { name: 'instrumented', version: '0.1.0' }, + cwd.path, + root.path, + ['.js'], + logger + ) + + await expect(relevantSourceFilesPromise).resolves.toHaveLength(1) + }) +}) diff --git a/src/test/scopes/js/import-matchers/js-all-import-matcher.test.ts b/src/test/scopes/js/import-matchers/js-all-import-matcher.test.ts new file mode 100644 index 00000000..aa40bb1e --- /dev/null +++ b/src/test/scopes/js/import-matchers/js-all-import-matcher.test.ts @@ -0,0 +1,127 @@ +/* + * Copyright IBM Corp. 2024, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import { describe, expect, it } from 'vitest' + +import { JsAllImportMatcher } from '../../../../main/scopes/js/import-matchers/js-all-import-matcher.js' +import type { JsFunction, JsImport, JsToken } from '../../../../main/scopes/js/interfaces.js' +import { DEFAULT_ELEMENT_NAME } from '../../../../main/scopes/jsx/constants.js' + +describe('class: JsAllImportMatcher', () => { + describe('tokens', () => { + const allImportMatcher = new JsAllImportMatcher() + const simpleJsToken: JsToken = { + name: 'theToken', + accessPath: ['theToken'], + startPos: 0, + endPos: 0 + } + const nestedJsToken: JsToken = { + name: 'theToken', + accessPath: ['object', 'theToken'], + startPos: 0, + endPos: 0 + } + const allImport: JsImport = { + name: 'object', + path: '@library/something', + isAll: true, + isDefault: false + } + const defaultImport: JsImport = { + name: DEFAULT_ELEMENT_NAME, + path: '@library/something', + rename: 'theToken', + isDefault: true, + isAll: false + } + const namedImport: JsImport = { + name: 'object', + path: '@library/something', + isDefault: false, + isAll: false + } + const renamedImport: JsImport = { + name: 'aToken', + path: '@library/something', + rename: 'theToken', + isDefault: false, + isAll: false + } + const imports = [allImport, namedImport, renamedImport, defaultImport] + it('correctly finds an import match', () => { + expect(allImportMatcher.findMatch(nestedJsToken, imports)).toStrictEqual(allImport) + }) + + it('returns undefined if no imports match token', () => { + expect( + allImportMatcher.findMatch(nestedJsToken, [namedImport, renamedImport, defaultImport]) + ).toBeUndefined() + }) + + it('returns undefined if token is not nested', () => { + expect(allImportMatcher.findMatch(simpleJsToken, imports)).toBeUndefined() + }) + }) + + describe('functions', () => { + const allImportMatcher = new JsAllImportMatcher() + const simpleJsFunction: JsFunction = { + name: 'theFunction', + accessPath: ['theFunction'], + arguments: [], + startPos: 0, + endPos: 0 + } + const nestedJsFunction: JsFunction = { + name: 'theFunction', + accessPath: ['object', 'theFunction'], + arguments: [], + startPos: 0, + endPos: 0 + } + const allImport: JsImport = { + name: 'object', + path: '@library/something', + isAll: true, + isDefault: false + } + const defaultImport: JsImport = { + name: DEFAULT_ELEMENT_NAME, + path: '@library/something', + rename: 'theFunction', + isDefault: true, + isAll: false + } + const namedImport: JsImport = { + name: 'object', + path: '@library/something', + isDefault: false, + isAll: false + } + const renamedImport: JsImport = { + name: 'aFunction', + path: '@library/something', + rename: 'theFunction', + isDefault: false, + isAll: false + } + const imports = [allImport, namedImport, renamedImport, defaultImport] + it('correctly finds an import match', () => { + expect(allImportMatcher.findMatch(nestedJsFunction, imports)).toStrictEqual(allImport) + }) + + it('returns undefined if no imports match function', () => { + expect( + allImportMatcher.findMatch(nestedJsFunction, [namedImport, renamedImport, defaultImport]) + ).toBeUndefined() + }) + + it('returns undefined if function is not nested', () => { + expect(allImportMatcher.findMatch(simpleJsFunction, imports)).toBeUndefined() + }) + }) +}) diff --git a/src/test/scopes/js/import-matchers/js-named-import-matcher.test.ts b/src/test/scopes/js/import-matchers/js-named-import-matcher.test.ts new file mode 100644 index 00000000..f142988c --- /dev/null +++ b/src/test/scopes/js/import-matchers/js-named-import-matcher.test.ts @@ -0,0 +1,126 @@ +/* + * Copyright IBM Corp. 2024, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import { describe, expect, it } from 'vitest' + +import { JsNamedImportMatcher } from '../../../../main/scopes/js/import-matchers/js-named-import-matcher.js' +import type { JsFunction, JsImport, JsToken } from '../../../../main/scopes/js/interfaces.js' +import { DEFAULT_ELEMENT_NAME } from '../../../../main/scopes/jsx/constants.js' + +describe('class: JsNamedImportMatcher', () => { + describe('tokens', () => { + const namedImportMatcher = new JsNamedImportMatcher() + const simpleJsToken: JsToken = { + name: 'object', + accessPath: ['object'], + startPos: 0, + endPos: 0 + } + const nestedJsToken: JsToken = { + name: 'object.theToken', + accessPath: ['object', 'theToken'], + startPos: 0, + endPos: 0 + } + const allImport: JsImport = { + name: 'object', + path: '@library/something', + isAll: true, + isDefault: false + } + const defaultImport: JsImport = { + name: DEFAULT_ELEMENT_NAME, + path: '@library/something', + rename: 'theToken', + isDefault: true, + isAll: false + } + const namedImport: JsImport = { + name: 'object', + path: '@library/something', + isDefault: false, + isAll: false + } + const renamedImport: JsImport = { + name: 'aToken', + path: '@library/something', + rename: 'theToken', + isDefault: false, + isAll: false + } + const imports = [allImport, namedImport, renamedImport, defaultImport] + it('correctly finds an import match with token name', () => { + expect(namedImportMatcher.findMatch(simpleJsToken, imports)).toStrictEqual(namedImport) + }) + + it('correctly finds an import match with token nesting', () => { + expect(namedImportMatcher.findMatch(nestedJsToken, imports)).toStrictEqual(namedImport) + }) + + it('returns undefined if no imports match token', () => { + expect( + namedImportMatcher.findMatch(simpleJsToken, [allImport, renamedImport, defaultImport]) + ).toBeUndefined() + }) + }) + describe('functions', () => { + const namedImportMatcher = new JsNamedImportMatcher() + const simpleJsFunction: JsFunction = { + name: 'function1', + accessPath: ['function1'], + arguments: [], + startPos: 0, + endPos: 0 + } + const nestedJsFunction: JsFunction = { + name: 'function2', + accessPath: ['function1', 'function2'], + arguments: [], + startPos: 0, + endPos: 0 + } + const allImport: JsImport = { + name: 'function1', + path: '@library/something', + isAll: true, + isDefault: false + } + const defaultImport: JsImport = { + name: DEFAULT_ELEMENT_NAME, + path: '@library/something', + rename: 'function2', + isDefault: true, + isAll: false + } + const namedImport: JsImport = { + name: 'function1', + path: '@library/something', + isDefault: false, + isAll: false + } + const renamedImport: JsImport = { + name: 'aFunction', + path: '@library/something', + rename: 'function2', + isDefault: false, + isAll: false + } + const imports = [allImport, namedImport, renamedImport, defaultImport] + it('correctly finds an import match with function name', () => { + expect(namedImportMatcher.findMatch(simpleJsFunction, imports)).toStrictEqual(namedImport) + }) + + it('correctly finds an import match with function nesting', () => { + expect(namedImportMatcher.findMatch(nestedJsFunction, imports)).toStrictEqual(namedImport) + }) + + it('returns undefined if no imports match function', () => { + expect( + namedImportMatcher.findMatch(simpleJsFunction, [allImport, renamedImport, defaultImport]) + ).toBeUndefined() + }) + }) +}) diff --git a/src/test/scopes/js/import-matchers/js-renamed-import-matcher.test.ts b/src/test/scopes/js/import-matchers/js-renamed-import-matcher.test.ts new file mode 100644 index 00000000..f50269a9 --- /dev/null +++ b/src/test/scopes/js/import-matchers/js-renamed-import-matcher.test.ts @@ -0,0 +1,139 @@ +/* + * Copyright IBM Corp. 2024, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import { describe, expect, it } from 'vitest' + +import { JsRenamedImportMatcher } from '../../../../main/scopes/js/import-matchers/js-renamed-import-matcher.js' +import type { JsFunction, JsImport, JsToken } from '../../../../main/scopes/js/interfaces.js' +import { DEFAULT_ELEMENT_NAME } from '../../../../main/scopes/jsx/constants.js' + +describe('class: JsRenamedImportMatcher', () => { + describe('tokens', () => { + const renamedImportMatcher = new JsRenamedImportMatcher() + const simpleJsToken: JsToken = { + name: 'object', + accessPath: ['object'], + startPos: 0, + endPos: 0 + } + const nestedJsToken: JsToken = { + name: 'object.theToken', + accessPath: ['object', 'theToken'], + startPos: 0, + endPos: 0 + } + const allImport: JsImport = { + name: 'object', + path: '@library/something', + isAll: true, + isDefault: false + } + const defaultImport: JsImport = { + name: DEFAULT_ELEMENT_NAME, + path: '@library/something', + rename: 'object', + isDefault: true, + isAll: false + } + const namedImport: JsImport = { + name: 'theToken', + path: '@library/something', + isDefault: false, + isAll: false + } + const renamedImport: JsImport = { + name: 'aToken', + path: '@library/something', + rename: 'object', + isDefault: false, + isAll: false + } + const imports = [allImport, namedImport, renamedImport, defaultImport] + it('correctly finds an import match with token name', () => { + expect(renamedImportMatcher.findMatch(simpleJsToken, imports)).toStrictEqual(renamedImport) + }) + + it('correctly finds an import match with nested token', () => { + expect(renamedImportMatcher.findMatch(nestedJsToken, imports)).toStrictEqual(renamedImport) + }) + + it('correctly finds an import match with default import', () => { + expect( + renamedImportMatcher.findMatch(simpleJsToken, [allImport, namedImport, defaultImport]) + ).toStrictEqual(defaultImport) + }) + + it('returns undefined if no imports match token', () => { + expect( + renamedImportMatcher.findMatch(simpleJsToken, [allImport, namedImport]) + ).toBeUndefined() + }) + }) + + describe('functions', () => { + const renamedImportMatcher = new JsRenamedImportMatcher() + const simpleJsFunction: JsFunction = { + name: 'function', + accessPath: ['function'], + arguments: [], + startPos: 0, + endPos: 0 + } + const nestedJsFunction: JsFunction = { + name: 'function1', + accessPath: ['function', 'function1'], + arguments: [], + startPos: 0, + endPos: 0 + } + const allImport: JsImport = { + name: 'function', + path: '@library/something', + isAll: true, + isDefault: false + } + const defaultImport: JsImport = { + name: DEFAULT_ELEMENT_NAME, + path: '@library/something', + rename: 'function', + isDefault: true, + isAll: false + } + const namedImport: JsImport = { + name: 'function', + path: '@library/something', + isDefault: false, + isAll: false + } + const renamedImport: JsImport = { + name: 'aFunction', + path: '@library/something', + rename: 'function', + isDefault: false, + isAll: false + } + const imports = [allImport, namedImport, renamedImport, defaultImport] + it('correctly finds an import match with function name', () => { + expect(renamedImportMatcher.findMatch(simpleJsFunction, imports)).toStrictEqual(renamedImport) + }) + + it('correctly finds an import match with nested function', () => { + expect(renamedImportMatcher.findMatch(nestedJsFunction, imports)).toStrictEqual(renamedImport) + }) + + it('correctly finds an import match with default import', () => { + expect( + renamedImportMatcher.findMatch(simpleJsFunction, [allImport, namedImport, defaultImport]) + ).toStrictEqual(defaultImport) + }) + + it('returns undefined if no imports match function', () => { + expect( + renamedImportMatcher.findMatch(simpleJsFunction, [allImport, namedImport]) + ).toBeUndefined() + }) + }) +}) diff --git a/src/test/scopes/js/js-scope.e2e.test.ts b/src/test/scopes/js/js-scope.e2e.test.ts new file mode 100644 index 00000000..fb026e1b --- /dev/null +++ b/src/test/scopes/js/js-scope.e2e.test.ts @@ -0,0 +1,466 @@ +/* + * Copyright IBM Corp. 2024, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import { type ConfigSchema } from '@ibm/telemetry-config-schema' +import { describe, expect, it } from 'vitest' + +import { EmptyScopeError } from '../../../main/exceptions/empty-scope.error.js' +import { JsAllImportMatcher } from '../../../main/scopes/js/import-matchers/js-all-import-matcher.js' +import { JsNamedImportMatcher } from '../../../main/scopes/js/import-matchers/js-named-import-matcher.js' +import { JsRenamedImportMatcher } from '../../../main/scopes/js/import-matchers/js-renamed-import-matcher.js' +import type { JsFunction, JsImport, JsToken } from '../../../main/scopes/js/interfaces.js' +import { JsFunctionTokenAccumulator } from '../../../main/scopes/js/js-function-token-accumulator.js' +import { jsNodeHandlerMap } from '../../../main/scopes/js/js-node-handler-map.js' +import { JsScope } from '../../../main/scopes/js/js-scope.js' +import { processFile } from '../../../main/scopes/js/process-file.js' +import { DEFAULT_ELEMENT_NAME } from '../../../main/scopes/jsx/constants.js' +import { clearDataPointTimes } from '../../__utils/clear-data-point-times.js' +import { clearTelemetrySdkVersion } from '../../__utils/clear-telemetry-sdk-version.js' +import { createSourceFileFromText } from '../../__utils/create-source-file-from-text.js' +import { Fixture } from '../../__utils/fixture.js' +import { initLogger } from '../../__utils/init-logger.js' +import { initializeOtelForTest } from '../../__utils/initialize-otel-for-test.js' + +const config: ConfigSchema = { + projectId: 'abc123', + version: 1, + endpoint: '', + collect: { + js: { + tokens: null, + functions: { + allowedArgumentStringValues: ['firstArg', 'secondArg'] + } + } + } +} + +describe('class: JsScope', () => { + const logger = initLogger() + describe('run', () => { + it('correctly captures js function and token metric data', async () => { + const metricReader = initializeOtelForTest().getMetricReader() + const root = new Fixture('projects/basic-project') + const cwd = new Fixture('projects/basic-project/node_modules/instrumented') + const jsScope = new JsScope(cwd.path, root.path, config, logger) + + jsScope.setRunSync(true) + await jsScope.run() + + const results = await metricReader.collect() + + clearTelemetrySdkVersion(results) + clearDataPointTimes(results) + + expect(results).toMatchSnapshot() + }) + + it('throws EmptyScopeError if no collector has been defined', async () => { + const fixture = new Fixture('projects/basic-project/node_modules/instrumented') + const root = new Fixture('projects/basic-project') + const scope = new JsScope( + fixture.path, + root.path, + { collect: { npm: {} }, projectId: '123', version: 1, endpoint: '' }, + logger + ) + + scope.setRunSync(true) + await expect(scope.run()).rejects.toThrow(EmptyScopeError) + }) + + it('only captures metrics for the instrumented package/version', async () => { + let metricReader = initializeOtelForTest().getMetricReader() + const root = new Fixture('projects/multiple-versions-of-instrumented-dep') + const pkgA = new Fixture( + 'projects/multiple-versions-of-instrumented-dep/node_modules/instrumented' + ) + const pkgB = new Fixture( + 'projects/multiple-versions-of-instrumented-dep/b/node_modules/instrumented' + ) + + let jsScope = new JsScope(pkgA.path, root.path, config, logger) + jsScope.setRunSync(true) + await jsScope.run() + const resultsA = await metricReader.collect() + + metricReader = initializeOtelForTest().getMetricReader() + + jsScope = new JsScope(pkgB.path, root.path, config, logger) + jsScope.setRunSync(true) + await jsScope.run() + const resultsB = await metricReader.collect() + + expect(resultsA.resourceMetrics.scopeMetrics[0]?.metrics[0]?.dataPoints).toHaveLength(1) + expect(resultsB.resourceMetrics.scopeMetrics[0]?.metrics[0]?.dataPoints).toHaveLength(2) + expect(resultsA.resourceMetrics.scopeMetrics[0]?.metrics[1]?.dataPoints).toHaveLength(1) + expect(resultsB.resourceMetrics.scopeMetrics[0]?.metrics[1]?.dataPoints).toHaveLength(2) + }) + + it('captures metrics when instrumented package is installed in intermediate package', async () => { + const metricReader = initializeOtelForTest().getMetricReader() + const root = new Fixture('projects/hoisted-deeply-nested-deps') + const cwd = new Fixture('projects/hoisted-deeply-nested-deps/node_modules/instrumented') + const jsScope = new JsScope(cwd.path, root.path, config, logger) + + jsScope.setRunSync(true) + await jsScope.run() + + const results = await metricReader.collect() + + clearTelemetrySdkVersion(results) + clearDataPointTimes(results) + + expect(results).toMatchSnapshot() + }) + + it('captures metrics for workspace files when instrumented package is installed by root package', async () => { + const metricReader = initializeOtelForTest().getMetricReader() + const root = new Fixture('projects/workspace-files-governed-by-root-dep') + const cwd = new Fixture( + 'projects/workspace-files-governed-by-root-dep/node_modules/instrumented-top-level' + ) + const jsScope = new JsScope(cwd.path, root.path, config, logger) + + jsScope.setRunSync(true) + await jsScope.run() + + const results = await metricReader.collect() + + clearTelemetrySdkVersion(results) + clearDataPointTimes(results) + + expect(results).toMatchSnapshot() + }) + }) + + describe('resolveTokenImports', () => { + const defaultImport: JsImport = { + name: DEFAULT_ELEMENT_NAME, + rename: 'nameDefault', + path: 'instrumented', + isDefault: true, + isAll: false + } + const allImport: JsImport = { + name: 'all', + path: 'instrumented', + isDefault: false, + isAll: true + } + const namedImport: JsImport = { + name: 'name', + path: 'instrumented', + isDefault: false, + isAll: false + } + const renamedImport: JsImport = { + name: 'renameName', + rename: 'rename', + path: 'instrumented', + isDefault: false, + isAll: false + } + const defaultToken: JsToken = { + name: 'nameDefault.prop', + accessPath: ['nameDefault', 'prop'], + startPos: 0, + endPos: 0 + } + const allToken: JsToken = { + name: 'all["whatever"]', + accessPath: ['all', 'whatever'], + startPos: 0, + endPos: 0 + } + const namedToken: JsToken = { + name: 'name.prop', + accessPath: ['name', 'prop'], + startPos: 0, + endPos: 0 + } + const renamedToken: JsToken = { + name: 'rename', + accessPath: ['rename'], + startPos: 0, + endPos: 0 + } + + it('correctly identifies tokens with their matchers', () => { + const accumulator = new JsFunctionTokenAccumulator() + accumulator.imports.push(defaultImport) + accumulator.imports.push(allImport) + accumulator.imports.push(namedImport) + accumulator.imports.push(renamedImport) + accumulator.tokens.push(defaultToken) + accumulator.tokens.push(allToken) + accumulator.tokens.push(namedToken) + accumulator.tokens.push(renamedToken) + + const jsScope = new JsScope('', '', config, logger) + + jsScope.setRunSync(true) + + jsScope.resolveTokenImports(accumulator, [ + new JsAllImportMatcher(), + new JsNamedImportMatcher(), + new JsRenamedImportMatcher() + ]) + + expect(accumulator.tokenImports.get(defaultToken)).toStrictEqual(defaultImport) + expect(accumulator.tokenImports.get(allToken)).toStrictEqual(allImport) + expect(accumulator.tokenImports.get(namedToken)).toStrictEqual(namedImport) + expect(accumulator.tokenImports.get(renamedToken)).toStrictEqual(renamedImport) + }) + + it('discards tokens that do not have a matcher', () => { + const unmatchedToken1: JsToken = { + name: 'noMatch1', + accessPath: ['noMatch1'], + startPos: 0, + endPos: 0 + } + const unmatchedToken2: JsToken = { + name: 'noMatch2', + accessPath: ['bla', 'noMatch2'], + startPos: 0, + endPos: 0 + } + const accumulator = new JsFunctionTokenAccumulator() + accumulator.imports.push(defaultImport) + accumulator.imports.push(allImport) + accumulator.imports.push(namedImport) + accumulator.imports.push(renamedImport) + accumulator.tokens.push(defaultToken) + accumulator.tokens.push(namedToken) + accumulator.tokens.push(renamedToken) + accumulator.tokens.push(unmatchedToken1) + accumulator.tokens.push(unmatchedToken2) + + const jsScope = new JsScope('', '', config, logger) + + jsScope.setRunSync(true) + + jsScope.resolveTokenImports(accumulator, [ + new JsAllImportMatcher(), + new JsNamedImportMatcher(), + new JsRenamedImportMatcher() + ]) + + expect(accumulator.tokenImports.get(defaultToken)).toStrictEqual(defaultImport) + expect(accumulator.tokenImports.get(namedToken)).toStrictEqual(namedImport) + expect(accumulator.tokenImports.get(renamedToken)).toStrictEqual(renamedImport) + expect(accumulator.tokenImports.get(unmatchedToken1)).toBeUndefined() + expect(accumulator.tokenImports.get(unmatchedToken2)).toBeUndefined() + }) + + it('can accept empty array', () => { + const jsScope = new JsScope('', '', config, logger) + + jsScope.setRunSync(true) + + const accumulator = new JsFunctionTokenAccumulator() + expect(() => { + jsScope.resolveTokenImports(accumulator, []) + }).not.toThrow() + }) + }) + + describe('resolveFunctionImports', () => { + const defaultImport: JsImport = { + name: DEFAULT_ELEMENT_NAME, + rename: 'nameDefault', + path: 'instrumented', + isDefault: true, + isAll: false + } + const allImport: JsImport = { + name: 'all', + path: 'instrumented', + isDefault: false, + isAll: true + } + const namedImport: JsImport = { + name: 'name', + path: 'instrumented', + isDefault: false, + isAll: false + } + const renamedImport: JsImport = { + name: 'renameName', + rename: 'rename', + path: 'instrumented', + isDefault: false, + isAll: false + } + const defaultFunction: JsFunction = { + name: 'nameDefault.functionName', + accessPath: ['nameDefault', 'functionName'], + arguments: [], + startPos: 0, + endPos: 0 + } + const allFunction: JsFunction = { + name: 'all["whatever"]', + accessPath: ['all', 'whatever'], + arguments: [], + startPos: 0, + endPos: 0 + } + const namedFunction: JsFunction = { + name: 'name', + accessPath: ['name'], + arguments: [], + startPos: 0, + endPos: 0 + } + const renamedFunction: JsFunction = { + name: 'rename', + accessPath: ['rename'], + arguments: [], + startPos: 0, + endPos: 0 + } + + it('correctly identifies functions with their matchers', () => { + const accumulator = new JsFunctionTokenAccumulator() + accumulator.imports.push(defaultImport) + accumulator.imports.push(allImport) + accumulator.imports.push(namedImport) + accumulator.imports.push(renamedImport) + accumulator.functions.push(defaultFunction) + accumulator.functions.push(allFunction) + accumulator.functions.push(namedFunction) + accumulator.functions.push(renamedFunction) + + const jsScope = new JsScope('', '', config, logger) + + jsScope.setRunSync(true) + + jsScope.resolveFunctionImports(accumulator, [ + new JsAllImportMatcher(), + new JsNamedImportMatcher(), + new JsRenamedImportMatcher() + ]) + + expect(accumulator.functionImports.get(defaultFunction)).toStrictEqual(defaultImport) + expect(accumulator.functionImports.get(allFunction)).toStrictEqual(allImport) + expect(accumulator.functionImports.get(namedFunction)).toStrictEqual(namedImport) + expect(accumulator.functionImports.get(renamedFunction)).toStrictEqual(renamedImport) + }) + + it('discards functions that do not have a matcher', () => { + const unmatchedFunction1: JsFunction = { + name: 'noMatch1', + accessPath: ['obj', 'noMatch1'], + arguments: [], + startPos: 0, + endPos: 0 + } + const unmatchedFunction2: JsFunction = { + name: 'noMatch2', + accessPath: ['noMatch2'], + arguments: [], + startPos: 0, + endPos: 0 + } + const accumulator = new JsFunctionTokenAccumulator() + accumulator.imports.push(defaultImport) + accumulator.imports.push(allImport) + accumulator.imports.push(namedImport) + accumulator.imports.push(renamedImport) + accumulator.functions.push(defaultFunction) + accumulator.functions.push(namedFunction) + accumulator.functions.push(renamedFunction) + accumulator.functions.push(unmatchedFunction1) + accumulator.functions.push(unmatchedFunction2) + + const jsScope = new JsScope('', '', config, logger) + jsScope.resolveFunctionImports(accumulator, [ + new JsAllImportMatcher(), + new JsNamedImportMatcher(), + new JsRenamedImportMatcher() + ]) + + expect(accumulator.functionImports.get(defaultFunction)).toStrictEqual(defaultImport) + expect(accumulator.functionImports.get(namedFunction)).toStrictEqual(namedImport) + expect(accumulator.functionImports.get(renamedFunction)).toStrictEqual(renamedImport) + expect(accumulator.functionImports.get(unmatchedFunction1)).toBeUndefined() + expect(accumulator.functionImports.get(unmatchedFunction2)).toBeUndefined() + }) + + it('can accept empty array', () => { + const jsScope = new JsScope('', '', config, logger) + + jsScope.setRunSync(true) + + const accumulator = new JsFunctionTokenAccumulator() + expect(() => { + jsScope.resolveTokenImports(accumulator, []) + }).not.toThrow() + }) + }) + + describe('deduplicateFunctions', () => { + it('does not remove self function', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo.bar().baz') + + processFile(accumulator, sourceFile, jsNodeHandlerMap, logger) + + expect(accumulator.functions).toHaveLength(1) + + new JsScope('', '', config, logger).deduplicateFunctions(accumulator) + expect(accumulator.functions).toHaveLength(1) + }) + + it('removes chained functions', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo().bar().baz()') + + processFile(accumulator, sourceFile, jsNodeHandlerMap, logger) + + expect(accumulator.functions).toHaveLength(3) + + new JsScope('', '', config, logger).deduplicateFunctions(accumulator) + expect(accumulator.functions).toHaveLength(1) + expect(accumulator.functions[0]).toStrictEqual({ + name: 'foo', + accessPath: ['foo'], + arguments: [], + startPos: 0, + endPos: 5 + }) + }) + }) + + describe('deduplicateTokens', () => { + it('removes property access attached to function call', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo().bar') + + processFile(accumulator, sourceFile, jsNodeHandlerMap, logger) + + expect(accumulator.tokens).toHaveLength(1) + + new JsScope('', '', config, logger).deduplicateTokens(accumulator) + + expect(accumulator.tokens).toHaveLength(0) + }) + + it('does not remove simple token', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo.bar') + + processFile(accumulator, sourceFile, jsNodeHandlerMap, logger) + + expect(accumulator.tokens).toHaveLength(1) + + new JsScope('', '', config, logger).deduplicateTokens(accumulator) + expect(accumulator.tokens).toHaveLength(1) + }) + }) +}) diff --git a/src/test/scopes/js/metrics/function-metric.test.ts b/src/test/scopes/js/metrics/function-metric.test.ts new file mode 100644 index 00000000..157cda72 --- /dev/null +++ b/src/test/scopes/js/metrics/function-metric.test.ts @@ -0,0 +1,272 @@ +/* + * Copyright IBM Corp. 2024, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import { JsScopeAttributes, NpmScopeAttributes } from '@ibm/telemetry-attributes-js' +import { type ConfigSchema } from '@ibm/telemetry-config-schema' +import { describe, expect, it } from 'vitest' + +import { hash } from '../../../../main/core/anonymize/hash.js' +import { substituteArray } from '../../../../main/core/anonymize/substitute-array.js' +import { ComplexValue } from '../../../../main/scopes/js/complex-value.js' +import type { JsFunction, JsImport } from '../../../../main/scopes/js/interfaces.js' +import { FunctionMetric } from '../../../../main/scopes/js/metrics/function-metric.js' +import { DEFAULT_ELEMENT_NAME } from '../../../../main/scopes/jsx/constants.js' +import { initLogger } from '../../../__utils/init-logger.js' + +const config: ConfigSchema = { + projectId: 'abc123', + version: 1, + endpoint: '', + collect: { + js: { + functions: { + allowedArgumentStringValues: ['allowedArg1', 'allowedArg2'] + } + } + } +} + +describe('class: FunctionMetric', () => { + const logger = initLogger() + const jsFunction: JsFunction = { + name: 'theFunction.access1.access2', + accessPath: ['theFunction', 'access1', 'access2'], + startPos: 0, + endPos: 10, + arguments: [true, 'allowedArg1', 'allowedArg2', 32, 'unallowedArg', new ComplexValue({})] + } + const jsImport: JsImport = { + name: 'theFunction', + path: 'path', + isDefault: false, + isAll: false + } + + it('returns the correct attributes for a standard function', () => { + const attributes = new FunctionMetric( + jsFunction, + jsImport, + { name: 'instrumented', version: '1.0.0' }, + config, + logger + ).attributes + + const subs = substituteArray(jsFunction.arguments, ['allowedArg1', 'allowedArg2']) + + expect(attributes).toStrictEqual( + hash( + { + [JsScopeAttributes.FUNCTION_NAME]: 'theFunction.access1.access2', + [JsScopeAttributes.FUNCTION_ACCESS_PATH]: 'theFunction access1 access2', + [JsScopeAttributes.FUNCTION_MODULE_SPECIFIER]: 'path', + [JsScopeAttributes.FUNCTION_ARGUMENT_VALUES]: Object.values(subs).map((arg) => + String(arg) + ), + [NpmScopeAttributes.INSTRUMENTED_RAW]: 'instrumented', + [NpmScopeAttributes.INSTRUMENTED_OWNER]: undefined, + [NpmScopeAttributes.INSTRUMENTED_NAME]: 'instrumented', + [NpmScopeAttributes.INSTRUMENTED_VERSION_RAW]: '1.0.0', + [NpmScopeAttributes.INSTRUMENTED_VERSION_MAJOR]: '1', + [NpmScopeAttributes.INSTRUMENTED_VERSION_MINOR]: '0', + [NpmScopeAttributes.INSTRUMENTED_VERSION_PATCH]: '0', + [NpmScopeAttributes.INSTRUMENTED_VERSION_PRE_RELEASE]: undefined + }, + [ + 'npm.dependency.instrumented.raw', + 'npm.dependency.instrumented.owner', + 'npm.dependency.instrumented.name', + 'npm.dependency.instrumented.version.raw', + 'npm.dependency.instrumented.version.preRelease' + ] + ) + ) + }) + + it('returns the correct attributes for a renamed function', () => { + const renamedImport = { ...jsImport, name: 'theActualName', rename: 'theFunction' } + const attributes = new FunctionMetric( + jsFunction, + renamedImport, + { + name: 'instrumented', + version: '1.0.0-rc.4' + }, + config, + logger + ).attributes + + const subs = substituteArray(jsFunction.arguments, ['allowedArg1', 'allowedArg2']) + + expect(attributes).toStrictEqual( + hash( + { + [JsScopeAttributes.FUNCTION_NAME]: 'theActualName.access1.access2', + [JsScopeAttributes.FUNCTION_ACCESS_PATH]: 'theActualName access1 access2', + [JsScopeAttributes.FUNCTION_MODULE_SPECIFIER]: 'path', + [JsScopeAttributes.FUNCTION_ARGUMENT_VALUES]: Object.values(subs).map((arg) => + String(arg) + ), + [NpmScopeAttributes.INSTRUMENTED_RAW]: 'instrumented', + [NpmScopeAttributes.INSTRUMENTED_OWNER]: undefined, + [NpmScopeAttributes.INSTRUMENTED_NAME]: 'instrumented', + [NpmScopeAttributes.INSTRUMENTED_VERSION_RAW]: '1.0.0-rc.4', + [NpmScopeAttributes.INSTRUMENTED_VERSION_MAJOR]: '1', + [NpmScopeAttributes.INSTRUMENTED_VERSION_MINOR]: '0', + [NpmScopeAttributes.INSTRUMENTED_VERSION_PATCH]: '0', + [NpmScopeAttributes.INSTRUMENTED_VERSION_PRE_RELEASE]: 'rc.4' + }, + [ + 'npm.dependency.instrumented.raw', + 'npm.dependency.instrumented.owner', + 'npm.dependency.instrumented.name', + 'npm.dependency.instrumented.version.raw', + 'npm.dependency.instrumented.version.preRelease' + ] + ) + ) + }) + + it('returns the correct attributes for a default function', () => { + const defaultImport = { + ...jsImport, + name: DEFAULT_ELEMENT_NAME, + rename: 'theFunction', + isDefault: true + } + const attributes = new FunctionMetric( + jsFunction, + defaultImport, + { + name: 'instrumented', + version: '1.0.0+9999' + }, + config, + logger + ).attributes + + const subs = substituteArray(jsFunction.arguments, ['allowedArg1', 'allowedArg2']) + + expect(attributes).toStrictEqual( + hash( + { + [JsScopeAttributes.FUNCTION_NAME]: `${DEFAULT_ELEMENT_NAME}.access1.access2`, + [JsScopeAttributes.FUNCTION_ACCESS_PATH]: `${DEFAULT_ELEMENT_NAME} access1 access2`, + [JsScopeAttributes.FUNCTION_MODULE_SPECIFIER]: 'path', + [JsScopeAttributes.FUNCTION_ARGUMENT_VALUES]: Object.values(subs).map((arg) => + String(arg) + ), + [NpmScopeAttributes.INSTRUMENTED_RAW]: 'instrumented', + [NpmScopeAttributes.INSTRUMENTED_OWNER]: undefined, + [NpmScopeAttributes.INSTRUMENTED_NAME]: 'instrumented', + [NpmScopeAttributes.INSTRUMENTED_VERSION_RAW]: '1.0.0+9999', + [NpmScopeAttributes.INSTRUMENTED_VERSION_MAJOR]: '1', + [NpmScopeAttributes.INSTRUMENTED_VERSION_MINOR]: '0', + [NpmScopeAttributes.INSTRUMENTED_VERSION_PATCH]: '0', + [NpmScopeAttributes.INSTRUMENTED_VERSION_PRE_RELEASE]: undefined + }, + [ + 'npm.dependency.instrumented.raw', + 'npm.dependency.instrumented.owner', + 'npm.dependency.instrumented.name', + 'npm.dependency.instrumented.version.raw', + 'npm.dependency.instrumented.version.preRelease' + ] + ) + ) + }) + + it('redacts an all import name and access path', () => { + const jsFunction: JsFunction = { + name: 'import.aFunction', + accessPath: ['import', 'aFunction'], + arguments: [], + startPos: 0, + endPos: 0 + } + const allImport: JsImport = { + name: 'import', + path: 'instrumented', + isDefault: false, + isAll: true + } + + const attributes = new FunctionMetric( + jsFunction, + allImport, + { + name: 'instrumented', + version: '1.0.0+9999' + }, + config, + logger + ).attributes + + expect(attributes[JsScopeAttributes.FUNCTION_NAME]).not.toContain('import') + expect(attributes[JsScopeAttributes.FUNCTION_ACCESS_PATH]).not.toContain('import') + }) + + it('redacts complex values', () => { + const jsFunction: JsFunction = { + name: 'import[complex["complex"]]', + accessPath: ['import', new ComplexValue('complex["complex"]')], + arguments: [], + startPos: 0, + endPos: 0 + } + + const namedImport: JsImport = { + name: 'import', + path: 'instrumented', + isDefault: false, + isAll: false + } + + const attributes = new FunctionMetric( + jsFunction, + namedImport, + { + name: 'instrumented', + version: '1.0.0+9999' + }, + config, + logger + ).attributes + + expect(attributes[JsScopeAttributes.FUNCTION_NAME]).not.toContain('complex["complex"]') + expect(attributes[JsScopeAttributes.FUNCTION_ACCESS_PATH]).not.toContain('complex["complex"]') + }) + + it('does not redact simple values', () => { + const jsFunction: JsFunction = { + name: 'import["simpleAccess"]', + accessPath: ['import', 'simpleAccess'], + arguments: [], + startPos: 0, + endPos: 0 + } + + const namedImport: JsImport = { + name: 'import', + path: 'instrumented', + isDefault: false, + isAll: false + } + + const attributes = new FunctionMetric( + jsFunction, + namedImport, + { + name: 'instrumented', + version: '1.0.0+9999' + }, + config, + logger + ).attributes + + expect(attributes[JsScopeAttributes.FUNCTION_NAME]).toBe('import["simpleAccess"]') + expect(attributes[JsScopeAttributes.FUNCTION_ACCESS_PATH]).toBe('import simpleAccess') + }) +}) diff --git a/src/test/scopes/js/metrics/token-metric.test.ts b/src/test/scopes/js/metrics/token-metric.test.ts new file mode 100644 index 00000000..12af3042 --- /dev/null +++ b/src/test/scopes/js/metrics/token-metric.test.ts @@ -0,0 +1,232 @@ +/* + * Copyright IBM Corp. 2024, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import { JsScopeAttributes, NpmScopeAttributes } from '@ibm/telemetry-attributes-js' +import { describe, expect, it } from 'vitest' + +import { hash } from '../../../../main/core/anonymize/hash.js' +import { ComplexValue } from '../../../../main/scopes/js/complex-value.js' +import type { JsImport, JsToken } from '../../../../main/scopes/js/interfaces.js' +import { TokenMetric } from '../../../../main/scopes/js/metrics/token-metric.js' +import { DEFAULT_ELEMENT_NAME } from '../../../../main/scopes/jsx/constants.js' +import { initLogger } from '../../../__utils/init-logger.js' + +describe('class: TokenMetric', () => { + const logger = initLogger() + const jsToken: JsToken = { + name: 'theToken.access1.access2', + accessPath: ['theToken', 'access1', 'access2'], + startPos: 0, + endPos: 0 + } + const jsImport: JsImport = { + name: 'theToken', + path: 'path', + isDefault: false, + isAll: false + } + + it('returns the correct attributes for a standard token', () => { + const attributes = new TokenMetric( + jsToken, + jsImport, + { name: 'instrumented', version: '1.0.0' }, + logger + ).attributes + + expect(attributes).toStrictEqual( + hash( + { + [JsScopeAttributes.TOKEN_NAME]: 'theToken.access1.access2', + [JsScopeAttributes.TOKEN_ACCESS_PATH]: 'theToken access1 access2', + [JsScopeAttributes.TOKEN_MODULE_SPECIFIER]: 'path', + [NpmScopeAttributes.INSTRUMENTED_RAW]: 'instrumented', + [NpmScopeAttributes.INSTRUMENTED_OWNER]: undefined, + [NpmScopeAttributes.INSTRUMENTED_NAME]: 'instrumented', + [NpmScopeAttributes.INSTRUMENTED_VERSION_RAW]: '1.0.0', + [NpmScopeAttributes.INSTRUMENTED_VERSION_MAJOR]: '1', + [NpmScopeAttributes.INSTRUMENTED_VERSION_MINOR]: '0', + [NpmScopeAttributes.INSTRUMENTED_VERSION_PATCH]: '0', + [NpmScopeAttributes.INSTRUMENTED_VERSION_PRE_RELEASE]: undefined + }, + [ + 'npm.dependency.instrumented.raw', + 'npm.dependency.instrumented.owner', + 'npm.dependency.instrumented.name', + 'npm.dependency.instrumented.version.raw', + 'npm.dependency.instrumented.version.preRelease' + ] + ) + ) + }) + + it('returns the correct attributes for a renamed token', () => { + const renamedImport = { ...jsImport, name: 'theActualName', rename: 'theToken' } + const attributes = new TokenMetric( + jsToken, + renamedImport, + { + name: 'instrumented', + version: '1.0.0-rc.4' + }, + logger + ).attributes + + expect(attributes).toStrictEqual( + hash( + { + [JsScopeAttributes.TOKEN_NAME]: 'theActualName.access1.access2', + [JsScopeAttributes.TOKEN_ACCESS_PATH]: 'theActualName access1 access2', + [JsScopeAttributes.TOKEN_MODULE_SPECIFIER]: 'path', + [NpmScopeAttributes.INSTRUMENTED_RAW]: 'instrumented', + [NpmScopeAttributes.INSTRUMENTED_OWNER]: undefined, + [NpmScopeAttributes.INSTRUMENTED_NAME]: 'instrumented', + [NpmScopeAttributes.INSTRUMENTED_VERSION_RAW]: '1.0.0-rc.4', + [NpmScopeAttributes.INSTRUMENTED_VERSION_MAJOR]: '1', + [NpmScopeAttributes.INSTRUMENTED_VERSION_MINOR]: '0', + [NpmScopeAttributes.INSTRUMENTED_VERSION_PATCH]: '0', + [NpmScopeAttributes.INSTRUMENTED_VERSION_PRE_RELEASE]: 'rc.4' + }, + [ + 'npm.dependency.instrumented.raw', + 'npm.dependency.instrumented.owner', + 'npm.dependency.instrumented.name', + 'npm.dependency.instrumented.version.raw', + 'npm.dependency.instrumented.version.preRelease' + ] + ) + ) + }) + + it('returns the correct attributes for a default token', () => { + const defaultImport = { + ...jsImport, + name: DEFAULT_ELEMENT_NAME, + rename: 'theToken', + isDefault: true + } + const attributes = new TokenMetric( + jsToken, + defaultImport, + { + name: 'instrumented', + version: '1.0.0+9999' + }, + logger + ).attributes + + expect(attributes).toStrictEqual( + hash( + { + [JsScopeAttributes.TOKEN_NAME]: `${DEFAULT_ELEMENT_NAME}.access1.access2`, + [JsScopeAttributes.TOKEN_ACCESS_PATH]: `${DEFAULT_ELEMENT_NAME} access1 access2`, + [JsScopeAttributes.TOKEN_MODULE_SPECIFIER]: 'path', + [NpmScopeAttributes.INSTRUMENTED_RAW]: 'instrumented', + [NpmScopeAttributes.INSTRUMENTED_OWNER]: undefined, + [NpmScopeAttributes.INSTRUMENTED_NAME]: 'instrumented', + [NpmScopeAttributes.INSTRUMENTED_VERSION_RAW]: '1.0.0+9999', + [NpmScopeAttributes.INSTRUMENTED_VERSION_MAJOR]: '1', + [NpmScopeAttributes.INSTRUMENTED_VERSION_MINOR]: '0', + [NpmScopeAttributes.INSTRUMENTED_VERSION_PATCH]: '0', + [NpmScopeAttributes.INSTRUMENTED_VERSION_PRE_RELEASE]: undefined + }, + [ + 'npm.dependency.instrumented.raw', + 'npm.dependency.instrumented.owner', + 'npm.dependency.instrumented.name', + 'npm.dependency.instrumented.version.raw', + 'npm.dependency.instrumented.version.preRelease' + ] + ) + ) + }) + + it('redacts an all import name and access path', () => { + const jsToken: JsToken = { + name: 'import.aToken', + accessPath: ['import', 'aToken'], + startPos: 0, + endPos: 0 + } + const allImport: JsImport = { + name: 'import', + path: 'instrumented', + isDefault: false, + isAll: true + } + + const attributes = new TokenMetric( + jsToken, + allImport, + { + name: 'instrumented', + version: '1.0.0+9999' + }, + logger + ).attributes + + expect(attributes[JsScopeAttributes.TOKEN_NAME]).not.toContain('import') + expect(attributes[JsScopeAttributes.TOKEN_ACCESS_PATH]).not.toContain('import') + }) + + it('redacts complex values', () => { + const jsToken: JsToken = { + name: 'import[complex["complex"]]', + accessPath: ['import', new ComplexValue('complex["complex"]')], + startPos: 0, + endPos: 0 + } + + const namedImport: JsImport = { + name: 'import', + path: 'instrumented', + isDefault: false, + isAll: false + } + + const attributes = new TokenMetric( + jsToken, + namedImport, + { + name: 'instrumented', + version: '1.0.0+9999' + }, + logger + ).attributes + + expect(attributes[JsScopeAttributes.TOKEN_NAME]).not.toContain('complex["complex"]') + expect(attributes[JsScopeAttributes.TOKEN_ACCESS_PATH]).not.toContain('complex["complex"]') + }) + + it('does not redact simple values', () => { + const jsToken: JsToken = { + name: 'import["simpleAccess"]', + accessPath: ['import', 'simpleAccess'], + startPos: 0, + endPos: 0 + } + + const namedImport: JsImport = { + name: 'import', + path: 'instrumented', + isDefault: false, + isAll: false + } + + const attributes = new TokenMetric( + jsToken, + namedImport, + { + name: 'instrumented', + version: '1.0.0+9999' + }, + logger + ).attributes + + expect(attributes[JsScopeAttributes.TOKEN_NAME]).toBe('import["simpleAccess"]') + expect(attributes[JsScopeAttributes.TOKEN_ACCESS_PATH]).toBe('import simpleAccess') + }) +}) diff --git a/src/test/scopes/js/node-handlers/tokens-and-functions-handlers/access-expression-node-handler.test.ts b/src/test/scopes/js/node-handlers/tokens-and-functions-handlers/access-expression-node-handler.test.ts new file mode 100644 index 00000000..e44f36f8 --- /dev/null +++ b/src/test/scopes/js/node-handlers/tokens-and-functions-handlers/access-expression-node-handler.test.ts @@ -0,0 +1,363 @@ +/* + * Copyright IBM Corp. 2024, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import * as ts from 'typescript' +import { describe, expect, it } from 'vitest' + +import { ComplexValue } from '../../../../../main/scopes/js/complex-value.js' +import { JsFunctionTokenAccumulator } from '../../../../../main/scopes/js/js-function-token-accumulator.js' +// eslint-disable-next-line max-len -- It's a long import +import { AccessExpressionNodeHandler } from '../../../../../main/scopes/js/node-handlers/tokens-and-functions-handlers/access-expression-node-handler.js' +import { createSourceFileFromText } from '../../../../__utils/create-source-file-from-text.js' +import { findNodesByType } from '../../../../__utils/find-nodes-by-type.js' +import { initLogger } from '../../../../__utils/init-logger.js' + +describe('class: AccessExpressionNodeHandler', async () => { + const logger = initLogger() + + describe('kind: ElementAccessExpression', () => { + it('captures a basic token usage by element access', () => { + const sourceFile = createSourceFileFromText('foo["bar"]') + const nodes = findNodesByType( + sourceFile, + ts.SyntaxKind.ElementAccessExpression + ) + const accumulator = new JsFunctionTokenAccumulator() + const handler = new AccessExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(1) + expect(accumulator.tokens[0]).toStrictEqual({ + name: 'foo["bar"]', + accessPath: ['foo', 'bar'], + startPos: 0, + endPos: 10 + }) + }) + + it('does not capture a token inside of a call expression', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo["bar"]["baz"]()') + const nodes = findNodesByType( + sourceFile, + ts.SyntaxKind.ElementAccessExpression + ) + const handler = new AccessExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(0) + }) + + it('does not capture a token for an identifier', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo') + const nodes = findNodesByType( + sourceFile, + ts.SyntaxKind.ElementAccessExpression + ) + const handler = new AccessExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(0) + }) + + it('does not capture a token for a property access expression', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('bar["asdf"].foo') + const nodes = findNodesByType( + sourceFile, + ts.SyntaxKind.ElementAccessExpression + ) + const handler = new AccessExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(0) + }) + + it('captures a token when accessor is a chain that ends with an element', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo.bar["asdf"]') + const nodes = findNodesByType( + sourceFile, + ts.SyntaxKind.ElementAccessExpression + ) + const handler = new AccessExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(1) + expect(accumulator.tokens[0]).toMatchObject({ + name: 'foo.bar["asdf"]', + accessPath: ['foo', 'bar', 'asdf'] + }) + }) + + it('does not capture a token for a function invocation', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo()') + const nodes = findNodesByType( + sourceFile, + ts.SyntaxKind.ElementAccessExpression + ) + const handler = new AccessExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(0) + }) + + it('captures token when used as complex element accessor', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo[ACCESS_TOKEN["bla"]].baz()') + const nodes = findNodesByType( + sourceFile, + ts.SyntaxKind.ElementAccessExpression + ) + const handler = new AccessExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(1) + expect(accumulator.tokens[0]).toMatchObject({ + name: 'ACCESS_TOKEN["bla"]', + accessPath: ['ACCESS_TOKEN', 'bla'] + }) + }) + + it('captures token when used as complex element accessor', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo[ACCESS_TOKEN["bla"]]') + const nodes = findNodesByType( + sourceFile, + ts.SyntaxKind.ElementAccessExpression + ) + const handler = new AccessExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(2) + expect(accumulator.tokens[0]).toMatchObject({ + name: 'foo[ACCESS_TOKEN["bla"]]', + accessPath: ['foo', new ComplexValue('ACCESS_TOKEN["bla"]')] + }) + expect(accumulator.tokens[1]).toMatchObject({ + name: 'ACCESS_TOKEN["bla"]', + accessPath: ['ACCESS_TOKEN', 'bla'] + }) + }) + }) + + describe('kind: PropertyAccessExpression', async () => { + const logger = initLogger() + + it('captures a basic token usage by property access', () => { + const sourceFile = createSourceFileFromText('foo.bar.baz') + const nodes = findNodesByType( + sourceFile, + ts.SyntaxKind.PropertyAccessExpression + ) + const accumulator = new JsFunctionTokenAccumulator() + const handler = new AccessExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(1) + expect(accumulator.tokens[0]).toMatchObject({ + name: 'foo.bar.baz', + accessPath: ['foo', 'bar', 'baz'] + }) + }) + + it('does not capture a token inside of a call expression', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo.bar.baz()') + const nodes = findNodesByType( + sourceFile, + ts.SyntaxKind.PropertyAccessExpression + ) + const handler = new AccessExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(0) + }) + + it('does not capture a token for an identifier', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo') + const nodes = findNodesByType( + sourceFile, + ts.SyntaxKind.PropertyAccessExpression + ) + const handler = new AccessExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(0) + }) + + it('does not capture a token for an element access expression', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo.bar["asdf"]') + const nodes = findNodesByType( + sourceFile, + ts.SyntaxKind.PropertyAccessExpression + ) + const handler = new AccessExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(0) + }) + + it('captures a token when accessor is a chain that ends with a property', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo["asdf"].bar') + const nodes = findNodesByType( + sourceFile, + ts.SyntaxKind.PropertyAccessExpression + ) + const handler = new AccessExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(1) + expect(accumulator.tokens[0]).toMatchObject({ + name: 'foo["asdf"].bar', + accessPath: ['foo', 'asdf', 'bar'] + }) + }) + + it('does not capture a token for a function invocation', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo()') + const nodes = findNodesByType( + sourceFile, + ts.SyntaxKind.PropertyAccessExpression + ) + const handler = new AccessExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(0) + }) + + it('does not capture a token for a property accessed off of a function call', () => { + // This is captured at this point, but will be filtered later on because: + // foo().bar <-- don't collect because if foo() returns something from a user, then .bar is + // potentially private information + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo().bar') + const nodes = findNodesByType( + sourceFile, + ts.SyntaxKind.PropertyAccessExpression + ) + const handler = new AccessExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(1) + expect(accumulator.tokens[0]).toMatchObject({ + name: 'foo().bar', + accessPath: ['foo', 'bar'] + }) + }) + + it('captures token when used as complex element accessor', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo[ACCESS_TOKEN["bla"]].baz') + const nodes = findNodesByType( + sourceFile, + ts.SyntaxKind.PropertyAccessExpression + ) + const handler = new AccessExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(1) + expect(accumulator.tokens[0]).toMatchObject({ + name: 'foo[ACCESS_TOKEN["bla"]].baz', + accessPath: ['foo', new ComplexValue('ACCESS_TOKEN["bla"]'), 'baz'] + }) + }) + + it('captures token when used as complex element accessor', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo[nested.property]') + const nodes = findNodesByType( + sourceFile, + ts.SyntaxKind.PropertyAccessExpression + ) + const handler = new AccessExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(1) + expect(accumulator.tokens[0]).toMatchObject({ + name: 'nested.property', + accessPath: ['nested', 'property'] + }) + }) + + it('captures token when accessed as undefined', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo[undefined]') + const nodes = findNodesByType( + sourceFile, + ts.SyntaxKind.ElementAccessExpression + ) + const handler = new AccessExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(1) + expect(accumulator.tokens[0]).toMatchObject({ + name: 'foo[undefined]', + accessPath: ['foo'] + }) + }) + }) +}) diff --git a/src/test/scopes/js/node-handlers/tokens-and-functions-handlers/call-expression-node-handler.test.ts b/src/test/scopes/js/node-handlers/tokens-and-functions-handlers/call-expression-node-handler.test.ts new file mode 100644 index 00000000..1b3b0ef5 --- /dev/null +++ b/src/test/scopes/js/node-handlers/tokens-and-functions-handlers/call-expression-node-handler.test.ts @@ -0,0 +1,137 @@ +/* + * Copyright IBM Corp. 2024, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import * as ts from 'typescript' +import { describe, expect, it } from 'vitest' + +import { ComplexValue } from '../../../../../main/scopes/js/complex-value.js' +import { JsFunctionTokenAccumulator } from '../../../../../main/scopes/js/js-function-token-accumulator.js' +// eslint-disable-next-line max-len -- It's a long import +import { CallExpressionNodeHandler } from '../../../../../main/scopes/js/node-handlers/tokens-and-functions-handlers/call-expression-node-handler.js' +import { createSourceFileFromText } from '../../../../__utils/create-source-file-from-text.js' +import { findNodesByType } from '../../../../__utils/find-nodes-by-type.js' +import { initLogger } from '../../../../__utils/init-logger.js' + +describe('class: CallExpressionExpressionNodeHandler', async () => { + const logger = initLogger() + + it('captures function at the end of a property chain', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo.bar.baz()') + const nodes = findNodesByType(sourceFile, ts.SyntaxKind.CallExpression) + const handler = new CallExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.functions).toHaveLength(1) + expect(accumulator.functions[0]).toStrictEqual({ + name: 'foo.bar.baz', + arguments: [], + accessPath: ['foo', 'bar', 'baz'], + startPos: 0, + endPos: 13 + }) + }) + + it('captures function at the end of a property chain', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo[ACCESS_TOKEN["bla"]].baz()') + const nodes = findNodesByType(sourceFile, ts.SyntaxKind.CallExpression) + const handler = new CallExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.functions).toHaveLength(1) + expect(accumulator.functions[0]).toMatchObject({ + name: 'foo[ACCESS_TOKEN["bla"]].baz', + arguments: [], + accessPath: ['foo', new ComplexValue('ACCESS_TOKEN["bla"]'), 'baz'] + }) + }) + + it('captures a function for a intermediate function', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo.bar().baz') + const nodes = findNodesByType(sourceFile, ts.SyntaxKind.CallExpression) + const handler = new CallExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.functions).toHaveLength(1) + expect(accumulator.functions[0]).toMatchObject({ + name: 'foo.bar', + arguments: [], + accessPath: ['foo', 'bar'] + }) + }) + + it('captures a function for a simple function', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo()') + const nodes = findNodesByType(sourceFile, ts.SyntaxKind.CallExpression) + const handler = new CallExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.functions).toHaveLength(1) + expect(accumulator.functions[0]).toMatchObject({ + name: 'foo', + arguments: [], + accessPath: ['foo'] + }) + }) + + it('??', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo().faa()') + const nodes = findNodesByType(sourceFile, ts.SyntaxKind.CallExpression) + const handler = new CallExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.functions).toHaveLength(2) + expect(accumulator.functions[1]).toMatchObject({ + name: 'foo', + arguments: [], + accessPath: ['foo'] + }) + expect(accumulator.functions[0]).toMatchObject({ + name: 'foo().faa', + arguments: [], + accessPath: ['foo', 'faa'] + }) + }) + + describe('functions containing arguments', () => { + it('captures a function and its arguments', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo(first)') + const nodes = findNodesByType(sourceFile, ts.SyntaxKind.CallExpression) + const handler = new CallExpressionNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.functions).toHaveLength(1) + expect(accumulator.functions[0]).toMatchObject({ + name: 'foo', + arguments: [new ComplexValue('first')], + accessPath: ['foo'] + }) + }) + }) +}) diff --git a/src/test/scopes/js/node-handlers/tokens-and-functions-handlers/identifier-node-handler.test.ts b/src/test/scopes/js/node-handlers/tokens-and-functions-handlers/identifier-node-handler.test.ts new file mode 100644 index 00000000..1db63886 --- /dev/null +++ b/src/test/scopes/js/node-handlers/tokens-and-functions-handlers/identifier-node-handler.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright IBM Corp. 2024, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import * as ts from 'typescript' +import { describe, expect, it } from 'vitest' + +import { JsFunctionTokenAccumulator } from '../../../../../main/scopes/js/js-function-token-accumulator.js' +import { IdentifierNodeHandler } from '../../../../../main/scopes/js/node-handlers/tokens-and-functions-handlers/identifier-node-handler.js' +import { createSourceFileFromText } from '../../../../__utils/create-source-file-from-text.js' +import { findNodesByType } from '../../../../__utils/find-nodes-by-type.js' +import { initLogger } from '../../../../__utils/init-logger.js' + +describe('new wow', () => { + const logger = initLogger() + + it('captures a token for a simple identifier', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo') + const nodes = findNodesByType(sourceFile, ts.SyntaxKind.Identifier) + const handler = new IdentifierNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(1) + expect(accumulator.tokens[0]).toStrictEqual({ + name: 'foo', + accessPath: ['foo'], + startPos: 0, + endPos: 3 + }) + }) + + it('captures a token for a nested identifier', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo[TOKEN]') + const nodes = findNodesByType(sourceFile, ts.SyntaxKind.Identifier) + const handler = new IdentifierNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(1) + expect(accumulator.tokens[0]).toMatchObject({ + name: 'TOKEN', + accessPath: ['TOKEN'] + }) + }) + + it('does not capture a token for a string access', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo["bla"]') + const nodes = findNodesByType(sourceFile, ts.SyntaxKind.Identifier) + const handler = new IdentifierNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(0) + }) + + it('does not capture any tokens for a chained property access', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo.bar.baz') + const nodes = findNodesByType(sourceFile, ts.SyntaxKind.Identifier) + const handler = new IdentifierNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(0) + }) + + it('does not capture any tokens for a function + property combo', () => { + const accumulator = new JsFunctionTokenAccumulator() + const sourceFile = createSourceFileFromText('foo().bar') + const nodes = findNodesByType(sourceFile, ts.SyntaxKind.Identifier) + const handler = new IdentifierNodeHandler(sourceFile, logger) + + nodes.forEach((node) => { + handler.handle(node, accumulator) + }) + + expect(accumulator.tokens).toHaveLength(0) + }) +}) diff --git a/src/test/scopes/jsx/__snapshots__/jsx-scope.e2e.test.ts.snap b/src/test/scopes/jsx/__snapshots__/jsx-scope.e2e.test.ts.snap index 994f65d0..53bb0125 100644 --- a/src/test/scopes/jsx/__snapshots__/jsx-scope.e2e.test.ts.snap +++ b/src/test/scopes/jsx/__snapshots__/jsx-scope.e2e.test.ts.snap @@ -1,62 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`class: JsxScope > parseFile > correctly detects imports and elements in a given file > elements 1`] = ` -[ - { - "attributes": [ - { - "name": "firstProp", - "value": ComplexValue { - "complexValue": "1 === 5 ? 'boo' : 'baa'", - }, - }, - { - "name": "simple", - "value": "4", - }, - { - "name": "woo", - "value": true, - }, - ], - "name": "Button", - "prefix": undefined, - }, - { - "attributes": [ - { - "name": "firstProp", - "value": ComplexValue { - "complexValue": "1 === 5 ? 'boo' : 'baa'", - }, - }, - { - "name": "simple", - "value": "4", - }, - { - "name": "woo", - "value": true, - }, - ], - "name": "Button", - "prefix": undefined, - }, -] -`; - -exports[`class: JsxScope > parseFile > correctly detects imports and elements in a given file > imports 1`] = ` -[ - { - "isAll": false, - "isDefault": true, - "name": "[Default]", - "path": "instrumented", - "rename": "Button", - }, -] -`; - exports[`class: JsxScope > run > captures metrics for workspace files when instrumented package is installed by root package 1`] = ` { "errors": [], diff --git a/src/test/scopes/jsx/jsx-scope.e2e.test.ts b/src/test/scopes/jsx/jsx-scope.e2e.test.ts index e6413b35..c4695d76 100644 --- a/src/test/scopes/jsx/jsx-scope.e2e.test.ts +++ b/src/test/scopes/jsx/jsx-scope.e2e.test.ts @@ -8,6 +8,7 @@ import { type ConfigSchema } from '@ibm/telemetry-config-schema' import { describe, expect, it } from 'vitest' import { EmptyScopeError } from '../../../main/exceptions/empty-scope.error.js' +import { DEFAULT_ELEMENT_NAME } from '../../../main/scopes/jsx/constants.js' import { JsxElementAllImportMatcher } from '../../../main/scopes/jsx/import-matchers/jsx-element-all-import-matcher.js' import { JsxElementNamedImportMatcher } from '../../../main/scopes/jsx/import-matchers/jsx-element-named-import-matcher.js' import { JsxElementRenamedImportMatcher } from '../../../main/scopes/jsx/import-matchers/jsx-element-renamed-import-matcher.js' @@ -128,26 +129,12 @@ describe('class: JsxScope', () => { expect(results).toMatchSnapshot() }) - - it('throws EmptyScopeError if no collector has been defined', async () => { - const fixture = new Fixture('projects/basic-project/node_modules/instrumented') - const root = new Fixture('projects/basic-project') - const jsxScope = new JsxScope( - fixture.path, - root.path, - { collect: { npm: {} }, projectId: '123', version: 1, endpoint: '' }, - logger - ) - - jsxScope.setRunSync(true) - await expect(jsxScope.run()).rejects.toThrow(EmptyScopeError) - }) }) describe('resolveElementImports', () => { const jsxScope = new JsxScope('', '', config, logger) const defaultImport = { - name: '[Default]', + name: DEFAULT_ELEMENT_NAME, rename: 'nameDefault', path: 'instrumented', isDefault: true, diff --git a/src/test/scopes/jsx/metrics/element-metric.test.ts b/src/test/scopes/jsx/metrics/element-metric.test.ts index fd112cb9..e3c2e685 100644 --- a/src/test/scopes/jsx/metrics/element-metric.test.ts +++ b/src/test/scopes/jsx/metrics/element-metric.test.ts @@ -9,8 +9,9 @@ import { type ConfigSchema } from '@ibm/telemetry-config-schema' import { describe, expect, it } from 'vitest' import { hash } from '../../../../main/core/anonymize/hash.js' -import { substitute } from '../../../../main/core/anonymize/substitute.js' +import { substituteObject } from '../../../../main/core/anonymize/substitute-object.js' import type { JsImport } from '../../../../main/scopes/js/interfaces.js' +import { DEFAULT_ELEMENT_NAME } from '../../../../main/scopes/jsx/constants.js' import type { JsxElement, JsxElementAttribute } from '../../../../main/scopes/jsx/interfaces.js' import { ElementMetric } from '../../../../main/scopes/jsx/metrics/element-metric.js' import { initLogger } from '../../../__utils/init-logger.js' @@ -63,7 +64,7 @@ describe('class: ElementMetric', () => { {} ) - const subs = substitute(attrMap, [], []) + const subs = substituteObject(attrMap, [], []) expect(attributes).toStrictEqual( hash( @@ -108,7 +109,7 @@ describe('class: ElementMetric', () => { return { ...prev, [cur.name]: cur.value } }, {}) - const subs = substitute(attrMap, [], []) + const subs = substituteObject(attrMap, [], []) expect(attributes).toStrictEqual( hash( @@ -138,7 +139,12 @@ describe('class: ElementMetric', () => { }) it('returns the correct attributes for a default element', () => { - const defaultImport = { ...jsImport, name: '[Default]', rename: 'theName', isDefault: true } + const defaultImport = { + ...jsImport, + name: DEFAULT_ELEMENT_NAME, + rename: 'theName', + isDefault: true + } const attributes = new ElementMetric( jsxElement, defaultImport, @@ -153,12 +159,12 @@ describe('class: ElementMetric', () => { return { ...prev, [cur.name]: cur.value } }, {}) - const subs = substitute(attrMap, [], []) + const subs = substituteObject(attrMap, [], []) expect(attributes).toStrictEqual( hash( { - [JsxScopeAttributes.NAME]: '[Default]', + [JsxScopeAttributes.NAME]: DEFAULT_ELEMENT_NAME, [JsxScopeAttributes.MODULE_SPECIFIER]: 'path', [JsxScopeAttributes.ATTRIBUTE_NAMES]: Object.keys(subs), [JsxScopeAttributes.ATTRIBUTE_VALUES]: Object.values(subs), @@ -222,7 +228,11 @@ describe('class: ElementMetric', () => { {} ) - const substitutedAttributes = substitute(attrMap, ['allowedAttrName'], ['allowedAttrValue']) + const substitutedAttributes = substituteObject( + attrMap, + ['allowedAttrName'], + ['allowedAttrValue'] + ) expect(attributes).toStrictEqual( hash(