diff --git a/.changeset/quiet-otters-listen.md b/.changeset/quiet-otters-listen.md new file mode 100644 index 00000000..158f5f66 --- /dev/null +++ b/.changeset/quiet-otters-listen.md @@ -0,0 +1,5 @@ +--- +"@nodesecure/js-x-ray": minor +--- + +feat(tracer): resolve identifiers assigned an object literal, so `log-usage` detects `pino()`/`winston.createLogger()` config passed via a variable instead of only inline diff --git a/workspaces/js-x-ray/src/VariableTracer.ts b/workspaces/js-x-ray/src/VariableTracer.ts index 1d7fdef3..b739a594 100644 --- a/workspaces/js-x-ray/src/VariableTracer.ts +++ b/workspaces/js-x-ray/src/VariableTracer.ts @@ -148,6 +148,13 @@ export class VariableTracer extends EventEmitter { // PUBLIC PROPERTIES literalIdentifiers = new Map(); + /** + * Resolves an identifier assigned an object literal back to its ObjectExpression node. + * @example + * const opts = { useOnlyCustomLevels: true }; + * pino(opts); // "opts" resolves via objectIdentifiers + */ + objectIdentifiers = new Map(); importedModules = new Set(); // PRIVATE PROPERTIES @@ -494,6 +501,12 @@ export class VariableTracer extends EventEmitter { * ^ ObjectExpression */ case "ObjectExpression": { + // Only record top-level assignments (`const x = {...}`) so consumers + // can resolve "x" back to its object shape, e.g. `pino(x)`. + if (childNode === variableDeclaratorNode.init) { + this.objectIdentifiers.set(id.name, childNode); + } + for (const property of childNode.properties) { let node: ESTree.Node | null = null; if (property.type === "Property") { diff --git a/workspaces/js-x-ray/src/probes/log-usage.ts b/workspaces/js-x-ray/src/probes/log-usage.ts index c9106140..41658177 100644 --- a/workspaces/js-x-ray/src/probes/log-usage.ts +++ b/workspaces/js-x-ray/src/probes/log-usage.ts @@ -123,7 +123,10 @@ function createWinstonCreateLoggerTracerListener(tracer: VariableTracer, logUsag let winstonLoggerMethods = winstonCreateLoggerChildLoggerFunctions.get(payload.name) ?? [...kWinstonLogMethods]; winston: if (payload.name === "winston.createLogger") { - const winstonContext = payload.arguments[0]; + const winstonContextArg = payload.arguments[0]; + const winstonContext = winstonContextArg && isIdentifier(winstonContextArg) ? + tracer.objectIdentifiers.get(winstonContextArg.name) : + winstonContextArg; if (!winstonContext || winstonContext.type !== "ObjectExpression") { break winston; } @@ -174,7 +177,10 @@ function createPinoTracerListener(tracer: VariableTracer, logUsages: Set let pinoLoggerMethods: string[] = pinoLoggerChildLoggerFunctions.get(payload.name) ?? [...kPinoLogMethods]; pino: if (payload.name === "pino") { - const pinoContext = payload.arguments[0]; + const pinoContextArg = payload.arguments[0]; + const pinoContext = pinoContextArg && isIdentifier(pinoContextArg) ? + tracer.objectIdentifiers.get(pinoContextArg.name) : + pinoContextArg; if (!pinoContext || pinoContext.type !== "ObjectExpression") { break pino; } diff --git a/workspaces/js-x-ray/test/VariableTracer/assignments.spec.ts b/workspaces/js-x-ray/test/VariableTracer/assignments.spec.ts index 18671c25..0669efa3 100644 --- a/workspaces/js-x-ray/test/VariableTracer/assignments.spec.ts +++ b/workspaces/js-x-ray/test/VariableTracer/assignments.spec.ts @@ -157,6 +157,29 @@ test("it should be able to Trace template literals who has being assigned", () = }); }); +test("it should be able to resolve an identifier assigned an object literal", () => { + const helpers = createTracer(); + + helpers.walkOnCode(` + const opts = { useOnlyCustomLevels: true, foo: "bar" }; + `); + assert.ok(helpers.tracer.objectIdentifiers.has("opts")); + + const objectNode = helpers.tracer.objectIdentifiers.get("opts")!; + assert.strictEqual(objectNode.type, "ObjectExpression"); + assert.strictEqual(objectNode.properties.length, 2); +}); + +test("it should not resolve a non top-level object literal as an identifier", () => { + const helpers = createTracer(); + + helpers.walkOnCode(` + const opts = { nested: { useOnlyCustomLevels: true } }; + `); + assert.ok(helpers.tracer.objectIdentifiers.has("opts")); + assert.strictEqual(helpers.tracer.objectIdentifiers.has("nested"), false); +}); + test("it should be able to Trace a global assignment using a LogicalExpression", () => { const helpers = createTracer(true); const assignments = helpers.getAssignmentArray(); diff --git a/workspaces/js-x-ray/test/probes/log-usage.spec.ts b/workspaces/js-x-ray/test/probes/log-usage.spec.ts index b4ec58ec..365a055d 100644 --- a/workspaces/js-x-ray/test/probes/log-usage.spec.ts +++ b/workspaces/js-x-ray/test/probes/log-usage.spec.ts @@ -383,6 +383,33 @@ describe("log-usage probe", () => { assert.strictEqual(firstWarning.value, "logger.foo, logger.bar"); }); + it("should resolve the whole pino() config object when passed as an identifier", () => { + const code = `import pino from "pino"; + + const opts = { + customLevels:{ + foo: 35, + bar: 36 + }, + useOnlyCustomLevels: true, + }; + const logger = pino(opts); + logger.info("hello"); + logger.warn("hello"); + logger.foo("hello"); + logger.bar("hello"); + `; + const { warnings } = new AstAnalyser({ + optionalWarnings: true + }).analyse(code); + + const [firstWarning] = warnings; + + assert.strictEqual(firstWarning.kind, "log-usage"); + assert.strictEqual(firstWarning.severity, "Information"); + assert.strictEqual(firstWarning.value, "logger.foo, logger.bar"); + }); + it("should trace default methods as well when useOnlyCustomLevels is false", () => { const code = `import pino from "pino"; const logger = pino({ @@ -876,6 +903,35 @@ describe("log-usage probe", () => { assert.strictEqual(firstWarning.value, "logger.foo, logger.bar"); }); + it("should resolve the whole winston.createLogger() config object when passed as an identifier", () => { + const code = `const winston = require("winston"); + const opts = { + format: winston.format.json(), + transports: [new winston.transports.Console()], + levels: { + foo: 1, + bar: 2 + } + }; + const logger = winston.createLogger(opts); + + logger.info("hello"); + logger.warn("hello"); + logger.foo("hello"); + logger.bar("hello"); + `; + + const { warnings } = new AstAnalyser({ + optionalWarnings: true + }).analyse(code); + + const [firstWarning] = warnings; + + assert.strictEqual(firstWarning.kind, "log-usage"); + assert.strictEqual(firstWarning.severity, "Information"); + assert.strictEqual(firstWarning.value, "logger.foo, logger.bar"); + }); + it("should have a child logger who inherit the custom levels from its parent logger", () => { const code = `import winston from "winston"; const logger = winston.createLogger({