From 52dba50786257805dca8fbdbbbe272e553a8d67f Mon Sep 17 00:00:00 2001 From: Chase J <54216608+chajac@users.noreply.github.com> Date: Mon, 14 Sep 2026 03:30:44 +0100 Subject: [PATCH 1/3] feat(flows): detect environment variable names passed to helpers --- .../envVarAnalysis/accessorScopes.test.ts | 79 +++++++++++++ src/core/envVarAnalysis/accessors.test.ts | 106 ++++++++++++++++++ src/core/envVarAnalysis/accessors.ts | 88 +++++++++++++++ src/core/envVarAnalysis/analysis.testUtils.ts | 5 + src/core/envVarAnalysis/callGraph.ts | 1 + src/core/envVarAnalysis/envReads.test.ts | 2 +- src/core/envVarAnalysis/envReads.ts | 10 +- src/core/envVarAnalysis/executionSummary.ts | 31 ++++- src/core/envVarAnalysis/executionUnits.ts | 16 +++ 9 files changed, 332 insertions(+), 6 deletions(-) create mode 100644 src/core/envVarAnalysis/accessorScopes.test.ts create mode 100644 src/core/envVarAnalysis/accessors.test.ts create mode 100644 src/core/envVarAnalysis/accessors.ts diff --git a/src/core/envVarAnalysis/accessorScopes.test.ts b/src/core/envVarAnalysis/accessorScopes.test.ts new file mode 100644 index 000000000..6ae2bbc3f --- /dev/null +++ b/src/core/envVarAnalysis/accessorScopes.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "bun:test"; + +import { analyse } from "./analysis.testUtils.js"; + +const flow = "src/flows/a.flow.ts"; + +describe("accessor scopes", () => { + it("resolves reverse-order forwarding chains without a depth limit", async () => { + const helpers = + Array.from( + { length: 20 }, + (_, index) => + `export function read${index}(name: string) { return read${index + 1}(name); }`, + ).join("\n") + + `\nexport function read20(name: string) { return process.env[name]; }`; + const result = await analyse({ + "src/lib/env.ts": helpers, + [flow]: `import { read0 } from "../lib/env.js"; export default () => read0("TOKEN");`, + }); + try { + expect(result.byFlow.get(flow)).toEqual({ + names: ["TOKEN"], + mayBeIncomplete: false, + }); + expect(result.accessorCount).toBe(21); + } finally { + await result.cleanup(); + } + }); + + it("resolves a literal passed to an accessor in the flow file", async () => { + const result = await analyse({ + [flow]: `function env(name: string) { return process.env[name]; } + export default () => env("TOKEN");`, + }); + try { + expect(result.byFlow.get(flow)).toEqual({ + names: ["TOKEN"], + mayBeIncomplete: false, + }); + } finally { + await result.cleanup(); + } + }); + + it("does not mistake a shadowed parameter for an accessor key", async () => { + const result = await analyse({ + [flow]: `function env(key: string) { + { const key = "ACTUAL"; return process.env[key]; } + } + export default () => env("FALSE_POSITIVE");`, + }); + try { + expect(result.byFlow.get(flow)?.names).not.toContain("FALSE_POSITIVE"); + expect(result.byFlow.get(flow)?.mayBeIncomplete).toBe(true); + expect(result.accessorCount).toBe(0); + } finally { + await result.cleanup(); + } + }); + + it("does not classify a helper by reads inside a dormant nested function", async () => { + const result = await analyse({ + [flow]: `function helper(name: string) { + function later(name: string) { return process.env[name]; } + return process.env.USED; + } + export default () => helper("NEVER");`, + }); + try { + expect(result.byFlow.get(flow)).toEqual({ + names: ["USED"], + mayBeIncomplete: false, + }); + } finally { + await result.cleanup(); + } + }); +}); diff --git a/src/core/envVarAnalysis/accessors.test.ts b/src/core/envVarAnalysis/accessors.test.ts new file mode 100644 index 000000000..f1d759446 --- /dev/null +++ b/src/core/envVarAnalysis/accessors.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "bun:test"; + +import { analyse } from "./analysis.testUtils.js"; + +const flow = "src/flows/a.flow.ts"; + +const envHelper = `export function readEnv(name: string) { return process.env[name]; } + export function requireEnv(name: string) { + const value = readEnv(name); + if (!value) throw new Error("missing " + name); + return value; + }`; + +describe("env accessors", () => { + it("takes the variable name from the call site", async () => { + const r = await analyse({ + "src/lib/env.ts": envHelper, + [flow]: `import { requireEnv } from "../lib/env.js"; + export default async () => requireEnv("ADMIN_TOKEN");`, + }); + expect(r.byFlow.get(flow)).toEqual({ + names: ["ADMIN_TOKEN"], + mayBeIncomplete: false, + }); + await r.cleanup(); + }); + + it("follows a forwarding chain to find the accessor", async () => { + const r = await analyse({ + "src/lib/env.ts": envHelper, + [flow]: "export default async () => 1;", + }); + expect(r.accessorCount).toBe(2); + await r.cleanup(); + }); + + it("does not flag a flow as incomplete for using the helper", async () => { + const r = await analyse({ + "src/lib/env.ts": envHelper, + [flow]: `import { requireEnv } from "../lib/env.js"; + export default async () => requireEnv("A") + requireEnv("B");`, + }); + expect(r.byFlow.get(flow)).toEqual({ + names: ["A", "B"], + mayBeIncomplete: false, + }); + await r.cleanup(); + }); + + it("reaches accessor calls made inside a page object", async () => { + const r = await analyse({ + "src/lib/env.ts": envHelper, + "src/pages/p.ts": `import { requireEnv } from "../lib/env.js"; + export class P { login() { return requireEnv("PAGE_TOKEN"); } }`, + [flow]: `import { P } from "../pages/p.js"; + export default async () => new P().login();`, + }); + expect(r.byFlow.get(flow)?.names).toEqual(["PAGE_TOKEN"]); + await r.cleanup(); + }); + + it("flags an accessor called with a non-literal", async () => { + const r = await analyse({ + "src/lib/env.ts": envHelper, + [flow]: `import { requireEnv } from "../lib/env.js"; + export default async (which: string) => requireEnv(which);`, + }); + expect(r.byFlow.get(flow)).toEqual({ names: [], mayBeIncomplete: true }); + await r.cleanup(); + }); + + it("resolves literals and flags variables in the same flow", async () => { + const r = await analyse({ + "src/lib/env.ts": envHelper, + [flow]: `import { requireEnv } from "../lib/env.js"; + export default async (which: string) => + requireEnv("KNOWN") + requireEnv(which);`, + }); + expect(r.byFlow.get(flow)).toEqual({ + names: ["KNOWN"], + mayBeIncomplete: true, + }); + await r.cleanup(); + }); + + it("handles an accessor whose key is not the first parameter", async () => { + const r = await analyse({ + "src/lib/env.ts": `export function get(fallback: string, name: string) { + return process.env[name] ?? fallback; + }`, + [flow]: `import { get } from "../lib/env.js"; + export default async () => get("none", "SECOND_SLOT");`, + }); + expect(r.byFlow.get(flow)?.names).toEqual(["SECOND_SLOT"]); + await r.cleanup(); + }); + + it("finds no accessors in a repo that reads process.env directly", async () => { + const r = await analyse({ + [flow]: `export default async () => process.env.DIRECT;`, + }); + expect(r.accessorCount).toBe(0); + expect(r.byFlow.get(flow)?.names).toEqual(["DIRECT"]); + await r.cleanup(); + }); +}); diff --git a/src/core/envVarAnalysis/accessors.ts b/src/core/envVarAnalysis/accessors.ts new file mode 100644 index 000000000..29462178b --- /dev/null +++ b/src/core/envVarAnalysis/accessors.ts @@ -0,0 +1,88 @@ +import type ts from "typescript"; + +import { calledDeclaration } from "./callableResolution.js"; +import { isProcessEnv, isReadAccess } from "./envReads.js"; +import { + functionExecution, + isFunctionLike, + walkExecuted, + type FunctionLike, +} from "./executionSyntax.js"; + +/** Maps a function to the parameter it uses as an environment key. */ +export type EnvAccessors = Map; + +type ForwardingCall = { + caller: FunctionLike; + argumentSlots: Map; +}; + +export function findEnvAccessors( + compiler: typeof ts, + checker: ts.TypeChecker, + sourceFiles: readonly ts.SourceFile[], +): EnvAccessors { + const accessors: EnvAccessors = new Map(); + const callers = new Map(); + const pending: ts.Node[] = []; + const add = (fn: ts.Node, slot: number): void => { + if (accessors.has(fn)) return; + accessors.set(fn, slot); + pending.push(fn); + }; + const index = (fn: FunctionLike): void => { + const parameters = new Map(); + fn.parameters.forEach((parameter, slot) => { + const symbol = checker.getSymbolAtLocation(parameter.name); + if (symbol !== undefined) parameters.set(symbol, slot); + }); + const parameterSlot = (node: ts.Node): number | undefined => { + const symbol = compiler.isIdentifier(node) + ? checker.getSymbolAtLocation(node) + : undefined; + return symbol === undefined ? undefined : parameters.get(symbol); + }; + for (const root of functionExecution(fn)) { + walkExecuted(compiler, root, (node) => { + if ( + compiler.isElementAccessExpression(node) && + isProcessEnv(compiler, node.expression) && + isReadAccess(compiler, node) + ) { + const slot = parameterSlot(node.argumentExpression); + if (slot !== undefined) add(fn, slot); + } + if (!compiler.isCallExpression(node)) return; + const callee = calledDeclaration(compiler, checker, node); + if (callee === undefined) return; + const argumentSlots = new Map(); + node.arguments.forEach((argument, argumentSlot) => { + const slot = parameterSlot(argument); + if (slot !== undefined) argumentSlots.set(argumentSlot, slot); + }); + if (argumentSlots.size === 0) return; + const entries = callers.get(callee) ?? []; + entries.push({ caller: fn, argumentSlots }); + callers.set(callee, entries); + }); + } + }; + const visit = (node: ts.Node): void => { + if (isFunctionLike(compiler, node)) index(node); + compiler.forEachChild(node, visit); + }; + for (const source of sourceFiles) visit(source); + + // Each discovered accessor visits only callers that might forward its key. + for (let cursor = 0; cursor < pending.length; cursor += 1) { + const callee = pending[cursor]; + if (callee === undefined) continue; + const keySlot = accessors.get(callee); + if (keySlot === undefined) continue; + for (const { caller, argumentSlots } of callers.get(callee) ?? []) { + const slot = argumentSlots.get(keySlot); + if (slot !== undefined) add(caller, slot); + } + } + return accessors; +} diff --git a/src/core/envVarAnalysis/analysis.testUtils.ts b/src/core/envVarAnalysis/analysis.testUtils.ts index fbdd82ef9..a40c2ea0a 100644 --- a/src/core/envVarAnalysis/analysis.testUtils.ts +++ b/src/core/envVarAnalysis/analysis.testUtils.ts @@ -5,12 +5,14 @@ import ts from "typescript"; import { isFlowFile } from "~/core/flowMeta.js"; +import { findEnvAccessors } from "./accessors.js"; import { collectEnvVarsByFlow } from "./callGraph.js"; import type { FlowEnvVars } from "./types.js"; export type AnalysisResult = { /** Keyed by the bundle-relative posix path each file was written under. */ byFlow: Map; + accessorCount: number; cleanup: () => Promise; }; @@ -45,9 +47,11 @@ export async function analyse( const sourceFiles = program .getSourceFiles() .filter((file) => local.has(normalize(file.fileName))); + const accessors = findEnvAccessors(ts, checker, sourceFiles); const absolute = collectEnvVarsByFlow({ compiler: ts, checker, + accessors, isLocalFile: (fileName) => local.has(normalize(fileName)), flowFiles: sourceFiles.filter((file) => isFlowFile(file.fileName)), }); @@ -62,6 +66,7 @@ export async function analyse( return { byFlow, + accessorCount: accessors.size, cleanup: () => rm(bundleDir, { recursive: true, force: true }), }; } diff --git a/src/core/envVarAnalysis/callGraph.ts b/src/core/envVarAnalysis/callGraph.ts index d70a86673..cfe65f7a7 100644 --- a/src/core/envVarAnalysis/callGraph.ts +++ b/src/core/envVarAnalysis/callGraph.ts @@ -63,6 +63,7 @@ export function collectEnvVarsByFlow( for (const entry of entries) { const summary = summaries.get(entry); if (summary !== undefined) mergeReads(reads, summary.reads); + if (args.accessors.has(entry)) reads.dynamic = true; } byFlow.set(flow.fileName, { names: [...reads.names].sort(), diff --git a/src/core/envVarAnalysis/envReads.test.ts b/src/core/envVarAnalysis/envReads.test.ts index b7d822851..ce12bb92e 100644 --- a/src/core/envVarAnalysis/envReads.test.ts +++ b/src/core/envVarAnalysis/envReads.test.ts @@ -13,7 +13,7 @@ function reads(source: string): { names: string[]; dynamic: boolean } { const names = new Set(); let dynamic = false; const visit = (node: ts.Node): void => { - const result = readEnvVarsFrom(ts, node); + const result = readEnvVarsFrom(ts, node, () => false); for (const name of result.names) names.add(name); dynamic ||= result.dynamic; ts.forEachChild(node, visit); diff --git a/src/core/envVarAnalysis/envReads.ts b/src/core/envVarAnalysis/envReads.ts index 30b76688d..379a664aa 100644 --- a/src/core/envVarAnalysis/envReads.ts +++ b/src/core/envVarAnalysis/envReads.ts @@ -2,7 +2,7 @@ import type ts from "typescript"; import type { EnvReads } from "./types.js"; -function isProcessEnv(compiler: typeof ts, node: ts.Node): boolean { +export function isProcessEnv(compiler: typeof ts, node: ts.Node): boolean { return ( compiler.isPropertyAccessExpression(node) && node.name.text === "env" && @@ -43,7 +43,11 @@ export function isReadAccess(compiler: typeof ts, node: ts.Node): boolean { } /** Reads on one visited syntax node; execution scope is controlled by the caller. */ -export function readEnvVarsFrom(compiler: typeof ts, node: ts.Node): EnvReads { +export function readEnvVarsFrom( + compiler: typeof ts, + node: ts.Node, + isKeyParameter: (node: ts.Node) => boolean, +): EnvReads { const names = new Set(); let dynamic = false; if ( @@ -61,7 +65,7 @@ export function readEnvVarsFrom(compiler: typeof ts, node: ts.Node): EnvReads { const argument = node.argumentExpression; if (compiler.isStringLiteralLike(argument)) { names.add(argument.text); - } else { + } else if (!isKeyParameter(argument)) { dynamic = true; } } diff --git a/src/core/envVarAnalysis/executionSummary.ts b/src/core/envVarAnalysis/executionSummary.ts index 1c2dfa917..105c28ff2 100644 --- a/src/core/envVarAnalysis/executionSummary.ts +++ b/src/core/envVarAnalysis/executionSummary.ts @@ -1,5 +1,6 @@ import type ts from "typescript"; +import type { EnvAccessors } from "./accessors.js"; import { calledDeclaration, resolveCallable, @@ -7,6 +8,7 @@ import { } from "./callableResolution.js"; import { isReadAccess, readEnvVarsFrom } from "./envReads.js"; import { + accessorKeyParameter, constructedClass, executedOnCall, implicitBaseClass, @@ -18,6 +20,7 @@ import type { EnvReads } from "./types.js"; export type WalkArgs = { readonly compiler: typeof ts; readonly checker: ts.TypeChecker; + readonly accessors: EnvAccessors; readonly isLocalFile: (fileName: string) => boolean; }; @@ -30,9 +33,19 @@ export function summarizeExecution( args: WalkArgs, declaration: ts.Node, ): ExecutionSummary { - const { compiler, checker, isLocalFile } = args; + const { compiler, checker, accessors, isLocalFile } = args; const reads: EnvReads = { names: new Set(), dynamic: false }; const callees = new Set(); + const keyParameter = accessorKeyParameter( + compiler, + checker, + accessors, + declaration, + ); + const isKeyParameter = (node: ts.Node): boolean => + keyParameter !== undefined && + compiler.isIdentifier(node) && + checker.getSymbolAtLocation(node) === keyParameter; const include = (callee: ts.Node | undefined): void => { if (callee !== undefined && isLocalFile(callee.getSourceFile().fileName)) callees.add(callee); @@ -59,6 +72,19 @@ export function summarizeExecution( const callee = constructedClass(compiler, checker, call) ?? calledDeclaration(compiler, checker, call); + const slot = callee === undefined ? undefined : accessors.get(callee); + if (slot !== undefined) { + const argument = call.arguments?.[slot]; + if ( + argument !== undefined && + compiler.isStringLiteralLike(argument) && + !argument.text.includes("${") + ) { + reads.names.add(argument.text); + } else if (argument === undefined || !isKeyParameter(argument)) { + reads.dynamic = true; + } + } include(callee); if ( callee !== undefined && @@ -75,10 +101,11 @@ export function summarizeExecution( const callback = resolveCallable(compiler, checker, argument); if (callback === undefined) continue; include(callback); + if (accessors.has(callback)) reads.dynamic = true; } }; const visit = (node: ts.Node): void => { - const direct = readEnvVarsFrom(compiler, node); + const direct = readEnvVarsFrom(compiler, node, isKeyParameter); for (const name of direct.names) reads.names.add(name); reads.dynamic ||= direct.dynamic; include(importedModule(compiler, checker, node)); diff --git a/src/core/envVarAnalysis/executionUnits.ts b/src/core/envVarAnalysis/executionUnits.ts index c83f33abf..1a16e1d65 100644 --- a/src/core/envVarAnalysis/executionUnits.ts +++ b/src/core/envVarAnalysis/executionUnits.ts @@ -1,5 +1,6 @@ import type ts from "typescript"; +import type { EnvAccessors } from "./accessors.js"; import { resolvedSymbol } from "./callableResolution.js"; import { functionExecution, @@ -77,3 +78,18 @@ export function implicitBaseClass( ); return hasConstructor ? undefined : baseClassOf(compiler, checker, node); } + +export function accessorKeyParameter( + compiler: typeof ts, + checker: ts.TypeChecker, + accessors: EnvAccessors, + declaration: ts.Node, +): ts.Symbol | undefined { + const slot = accessors.get(declaration); + if (slot === undefined || !isFunctionLike(compiler, declaration)) + return undefined; + const parameter = declaration.parameters[slot]; + return parameter === undefined + ? undefined + : checker.getSymbolAtLocation(parameter.name); +} From c1b02b342878cb737808a627beca0cc8b50aaafd Mon Sep 17 00:00:00 2001 From: Chase J <54216608+chajac@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:47:47 +0100 Subject: [PATCH 2/3] fix(flows): trace every unchanged environment key --- .../accessorConstructors.test.ts | 48 +++++++++++ src/core/envVarAnalysis/accessorKeys.test.ts | 67 +++++++++++++++ .../envVarAnalysis/accessorWrites.test.ts | 85 +++++++++++++++++++ src/core/envVarAnalysis/accessors.ts | 33 ++++--- src/core/envVarAnalysis/executionSummary.ts | 28 +++--- src/core/envVarAnalysis/executionUnits.ts | 44 ++++++++-- src/core/envVarAnalysis/stableParameters.ts | 60 +++++++++++++ 7 files changed, 326 insertions(+), 39 deletions(-) create mode 100644 src/core/envVarAnalysis/accessorConstructors.test.ts create mode 100644 src/core/envVarAnalysis/accessorKeys.test.ts create mode 100644 src/core/envVarAnalysis/accessorWrites.test.ts create mode 100644 src/core/envVarAnalysis/stableParameters.ts diff --git a/src/core/envVarAnalysis/accessorConstructors.test.ts b/src/core/envVarAnalysis/accessorConstructors.test.ts new file mode 100644 index 000000000..9501dad40 --- /dev/null +++ b/src/core/envVarAnalysis/accessorConstructors.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "bun:test"; + +import { analyse } from "./analysis.testUtils.js"; + +const flow = "src/flows/a.flow.ts"; +const reader = `class Reader { + field = process.env.FIELD; + constructor(key: string) { console.log(process.env[key]); } +}`; + +describe("constructor accessor keys", () => { + it.each([ + 'export default () => new Reader("TOKEN");', + 'function read(key: string) { return new Reader(key); } export default () => read("TOKEN");', + 'class Derived extends Reader {} export default () => new Derived("TOKEN");', + `class Derived extends Reader { + constructor(key: string) { super(key); } + } export default () => new Derived("TOKEN");`, + ])("resolves constructor keys with instance reads: %s", async (source) => { + const result = await analyse({ [flow]: `${reader} ${source}` }); + try { + expect(result.byFlow.get(flow)).toEqual({ + names: ["FIELD", "TOKEN"], + mayBeIncomplete: false, + }); + } finally { + await result.cleanup(); + } + }); + + it("resolves every constructor key slot", async () => { + const result = await analyse({ + [flow]: `class Reader { + constructor(first: string, second: string) { + console.log(process.env[first], process.env[second]); + } + } export default () => new Reader("FIRST", "SECOND");`, + }); + try { + expect(result.byFlow.get(flow)).toEqual({ + names: ["FIRST", "SECOND"], + mayBeIncomplete: false, + }); + } finally { + await result.cleanup(); + } + }); +}); diff --git a/src/core/envVarAnalysis/accessorKeys.test.ts b/src/core/envVarAnalysis/accessorKeys.test.ts new file mode 100644 index 000000000..6ef9a95ba --- /dev/null +++ b/src/core/envVarAnalysis/accessorKeys.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "bun:test"; + +import { analyse } from "./analysis.testUtils.js"; + +const flow = "src/flows/a.flow.ts"; + +describe("accessor keys", () => { + it.each([ + `function read(first: string, second: string) { + return process.env[first] + process.env[second]; + }`, + `function env(key: string) { return process.env[key]; } + function read(first: string, second: string) { + return env(first) + env(second); + }`, + `function read(first: string, second: string): unknown { + return next(first, second); + } + function next(first: string, second: string): unknown { + return process.env[first] ?? process.env[second] ?? read(first, second); + }`, + ])("resolves every key slot through calls: %s", async (helper) => { + const result = await analyse({ + [flow]: `${helper} export default () => read("FIRST", "SECOND");`, + }); + try { + expect(result.byFlow.get(flow)).toEqual({ + names: ["FIRST", "SECOND"], + mayBeIncomplete: false, + }); + } finally { + await result.cleanup(); + } + }); + + it("accepts literal names containing template markers", async () => { + const result = await analyse({ + [flow]: + "function env(key: string) { return process.env[key]; }\n" + + 'export default () => [env("TOKEN${SUFFIX}"), env(`LITERAL\\${KEY}`)];', + }); + try { + expect(result.byFlow.get(flow)).toEqual({ + names: ["LITERAL${KEY}", "TOKEN${SUFFIX}"], + mayBeIncomplete: false, + }); + } finally { + await result.cleanup(); + } + }); + + it("keeps interpolated key expressions incomplete", async () => { + const result = await analyse({ + [flow]: + "function env(key: string) { return process.env[key]; }\n" + + "export default (suffix: string) => env(`TOKEN${suffix}`);", + }); + try { + expect(result.byFlow.get(flow)).toEqual({ + names: [], + mayBeIncomplete: true, + }); + } finally { + await result.cleanup(); + } + }); +}); diff --git a/src/core/envVarAnalysis/accessorWrites.test.ts b/src/core/envVarAnalysis/accessorWrites.test.ts new file mode 100644 index 000000000..86154f02b --- /dev/null +++ b/src/core/envVarAnalysis/accessorWrites.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "bun:test"; + +import { analyse } from "./analysis.testUtils.js"; + +const flow = "src/flows/a.flow.ts"; + +describe("accessor writes", () => { + it.each([ + 'key = "OTHER";', + 'key += "_OTHER";', + "++key;", + "key++;", + '[key] = ["OTHER"];', + '({ key } = { key: "OTHER" });', + 'for (key of ["OTHER"]) {}', + "for (key in { OTHER: true }) {}", + 'function change() { key = "OTHER"; } change();', + ])("does not infer a key after a write: %s", async (write) => { + const result = await analyse({ + [flow]: `function env(key: string) { ${write} return process.env[key]; } + export default () => env("INPUT");`, + }); + try { + expect(result.byFlow.get(flow)).toEqual({ + names: [], + mayBeIncomplete: true, + }); + expect(result.accessorCount).toBe(0); + } finally { + await result.cleanup(); + } + }); + + it("does not forward a reassigned key", async () => { + const result = await analyse({ + [flow]: `function env(key: string) { return process.env[key]; } + function read(key: string) { key = "OTHER"; return env(key); } + export default () => read("INPUT");`, + }); + try { + expect(result.byFlow.get(flow)).toEqual({ + names: [], + mayBeIncomplete: true, + }); + } finally { + await result.cleanup(); + } + }); + + it("retains untouched key slots beside a reassigned slot", async () => { + const result = await analyse({ + [flow]: `function env(first: string, second: string) { + first = "OTHER"; + return process.env[first] + process.env[second]; + } + export default () => env("INPUT", "SECOND");`, + }); + try { + expect(result.byFlow.get(flow)).toEqual({ + names: ["SECOND"], + mayBeIncomplete: true, + }); + } finally { + await result.cleanup(); + } + }); + + it("does not invalidate a parameter for a shadowed write", async () => { + const result = await analyse({ + [flow]: `function env(key: string) { + { let key = "LOCAL"; key = "OTHER"; } + return process.env[key]; + } + export default () => env("INPUT");`, + }); + try { + expect(result.byFlow.get(flow)).toEqual({ + names: ["INPUT"], + mayBeIncomplete: false, + }); + } finally { + await result.cleanup(); + } + }); +}); diff --git a/src/core/envVarAnalysis/accessors.ts b/src/core/envVarAnalysis/accessors.ts index 29462178b..543852667 100644 --- a/src/core/envVarAnalysis/accessors.ts +++ b/src/core/envVarAnalysis/accessors.ts @@ -2,6 +2,7 @@ import type ts from "typescript"; import { calledDeclaration } from "./callableResolution.js"; import { isProcessEnv, isReadAccess } from "./envReads.js"; +import { stableParameterSlots } from "./stableParameters.js"; import { functionExecution, isFunctionLike, @@ -9,8 +10,8 @@ import { type FunctionLike, } from "./executionSyntax.js"; -/** Maps a function to the parameter it uses as an environment key. */ -export type EnvAccessors = Map; +/** Maps a function to every parameter it uses as an environment key. */ +export type EnvAccessors = Map>; type ForwardingCall = { caller: FunctionLike; @@ -24,18 +25,16 @@ export function findEnvAccessors( ): EnvAccessors { const accessors: EnvAccessors = new Map(); const callers = new Map(); - const pending: ts.Node[] = []; + const pending: { callee: ts.Node; keySlot: number }[] = []; const add = (fn: ts.Node, slot: number): void => { - if (accessors.has(fn)) return; - accessors.set(fn, slot); - pending.push(fn); + const slots = accessors.get(fn) ?? new Set(); + if (slots.has(slot)) return; + slots.add(slot); + accessors.set(fn, slots); + pending.push({ callee: fn, keySlot: slot }); }; const index = (fn: FunctionLike): void => { - const parameters = new Map(); - fn.parameters.forEach((parameter, slot) => { - const symbol = checker.getSymbolAtLocation(parameter.name); - if (symbol !== undefined) parameters.set(symbol, slot); - }); + const parameters = stableParameterSlots(compiler, checker, fn); const parameterSlot = (node: ts.Node): number | undefined => { const symbol = compiler.isIdentifier(node) ? checker.getSymbolAtLocation(node) @@ -52,11 +51,12 @@ export function findEnvAccessors( const slot = parameterSlot(node.argumentExpression); if (slot !== undefined) add(fn, slot); } - if (!compiler.isCallExpression(node)) return; + if (!compiler.isCallExpression(node) && !compiler.isNewExpression(node)) + return; const callee = calledDeclaration(compiler, checker, node); if (callee === undefined) return; const argumentSlots = new Map(); - node.arguments.forEach((argument, argumentSlot) => { + node.arguments?.forEach((argument, argumentSlot) => { const slot = parameterSlot(argument); if (slot !== undefined) argumentSlots.set(argumentSlot, slot); }); @@ -75,10 +75,9 @@ export function findEnvAccessors( // Each discovered accessor visits only callers that might forward its key. for (let cursor = 0; cursor < pending.length; cursor += 1) { - const callee = pending[cursor]; - if (callee === undefined) continue; - const keySlot = accessors.get(callee); - if (keySlot === undefined) continue; + const entry = pending[cursor]; + if (entry === undefined) continue; + const { callee, keySlot } = entry; for (const { caller, argumentSlots } of callers.get(callee) ?? []) { const slot = argumentSlots.get(keySlot); if (slot !== undefined) add(caller, slot); diff --git a/src/core/envVarAnalysis/executionSummary.ts b/src/core/envVarAnalysis/executionSummary.ts index 105c28ff2..702d2bc78 100644 --- a/src/core/envVarAnalysis/executionSummary.ts +++ b/src/core/envVarAnalysis/executionSummary.ts @@ -8,7 +8,8 @@ import { } from "./callableResolution.js"; import { isReadAccess, readEnvVarsFrom } from "./envReads.js"; import { - accessorKeyParameter, + accessorDeclaration, + accessorKeyParameters, constructedClass, executedOnCall, implicitBaseClass, @@ -36,16 +37,18 @@ export function summarizeExecution( const { compiler, checker, accessors, isLocalFile } = args; const reads: EnvReads = { names: new Set(), dynamic: false }; const callees = new Set(); - const keyParameter = accessorKeyParameter( + const keyParameters = accessorKeyParameters( compiler, checker, accessors, declaration, ); - const isKeyParameter = (node: ts.Node): boolean => - keyParameter !== undefined && - compiler.isIdentifier(node) && - checker.getSymbolAtLocation(node) === keyParameter; + const isKeyParameter = (node: ts.Node): boolean => { + const symbol = compiler.isIdentifier(node) + ? checker.getSymbolAtLocation(node) + : undefined; + return symbol !== undefined && keyParameters.has(symbol); + }; const include = (callee: ts.Node | undefined): void => { if (callee !== undefined && isLocalFile(callee.getSourceFile().fileName)) callees.add(callee); @@ -72,14 +75,13 @@ export function summarizeExecution( const callee = constructedClass(compiler, checker, call) ?? calledDeclaration(compiler, checker, call); - const slot = callee === undefined ? undefined : accessors.get(callee); - if (slot !== undefined) { + const slots = + callee === undefined + ? undefined + : accessors.get(accessorDeclaration(compiler, checker, callee)); + for (const slot of slots ?? []) { const argument = call.arguments?.[slot]; - if ( - argument !== undefined && - compiler.isStringLiteralLike(argument) && - !argument.text.includes("${") - ) { + if (argument !== undefined && compiler.isStringLiteralLike(argument)) { reads.names.add(argument.text); } else if (argument === undefined || !isKeyParameter(argument)) { reads.dynamic = true; diff --git a/src/core/envVarAnalysis/executionUnits.ts b/src/core/envVarAnalysis/executionUnits.ts index 1a16e1d65..861029c54 100644 --- a/src/core/envVarAnalysis/executionUnits.ts +++ b/src/core/envVarAnalysis/executionUnits.ts @@ -79,17 +79,43 @@ export function implicitBaseClass( return hasConstructor ? undefined : baseClassOf(compiler, checker, node); } -export function accessorKeyParameter( +export function accessorDeclaration( + compiler: typeof ts, + checker: ts.TypeChecker, + declaration: ts.Node, +): ts.Node { + let current = declaration; + const seen = new Set(); + while (compiler.isClassLike(current) && !seen.has(current)) { + seen.add(current); + const constructor = current.members.find( + (member) => + compiler.isConstructorDeclaration(member) && member.body !== undefined, + ); + if (constructor !== undefined) return constructor; + const base = baseClassOf(compiler, checker, current); + if (base === undefined) break; + current = base; + } + return current; +} + +export function accessorKeyParameters( compiler: typeof ts, checker: ts.TypeChecker, accessors: EnvAccessors, declaration: ts.Node, -): ts.Symbol | undefined { - const slot = accessors.get(declaration); - if (slot === undefined || !isFunctionLike(compiler, declaration)) - return undefined; - const parameter = declaration.parameters[slot]; - return parameter === undefined - ? undefined - : checker.getSymbolAtLocation(parameter.name); +): Set { + const fn = accessorDeclaration(compiler, checker, declaration); + const parameters = new Set(); + if (!isFunctionLike(compiler, fn)) return parameters; + for (const slot of accessors.get(fn) ?? []) { + const parameter = fn.parameters[slot]; + const symbol = + parameter === undefined + ? undefined + : checker.getSymbolAtLocation(parameter.name); + if (symbol !== undefined) parameters.add(symbol); + } + return parameters; } diff --git a/src/core/envVarAnalysis/stableParameters.ts b/src/core/envVarAnalysis/stableParameters.ts new file mode 100644 index 000000000..8b1619f73 --- /dev/null +++ b/src/core/envVarAnalysis/stableParameters.ts @@ -0,0 +1,60 @@ +import type ts from "typescript"; + +import type { FunctionLike } from "./executionSyntax.js"; + +function isWrite(compiler: typeof ts, node: ts.Node): boolean { + let target = node; + while ( + compiler.isParenthesizedExpression(target.parent) || + compiler.isAsExpression(target.parent) || + compiler.isTypeAssertionExpression(target.parent) || + compiler.isNonNullExpression(target.parent) || + compiler.isSatisfiesExpression(target.parent) || + compiler.isShorthandPropertyAssignment(target.parent) || + (compiler.isPropertyAssignment(target.parent) && + target.parent.initializer === target) || + compiler.isObjectLiteralExpression(target.parent) || + compiler.isArrayLiteralExpression(target.parent) || + compiler.isSpreadAssignment(target.parent) || + compiler.isSpreadElement(target.parent) + ) { + target = target.parent; + } + const parent = target.parent; + return ( + (compiler.isBinaryExpression(parent) && + parent.left === target && + parent.operatorToken.kind >= compiler.SyntaxKind.FirstAssignment && + parent.operatorToken.kind <= compiler.SyntaxKind.LastAssignment) || + ((compiler.isForInStatement(parent) || compiler.isForOfStatement(parent)) && + parent.initializer === target) || + compiler.isPostfixUnaryExpression(parent) || + (compiler.isPrefixUnaryExpression(parent) && + (parent.operator === compiler.SyntaxKind.PlusPlusToken || + parent.operator === compiler.SyntaxKind.MinusMinusToken)) + ); +} + +/** Parameter slots whose original arguments are not invalidated by writes. */ +export function stableParameterSlots( + compiler: typeof ts, + checker: ts.TypeChecker, + fn: FunctionLike, +): Map { + const parameters = new Map(); + fn.parameters.forEach((parameter, slot) => { + const symbol = checker.getSymbolAtLocation(parameter.name); + if (symbol !== undefined) parameters.set(symbol, slot); + }); + const visit = (node: ts.Node): void => { + if (compiler.isIdentifier(node) && isWrite(compiler, node)) { + const symbol = compiler.isShorthandPropertyAssignment(node.parent) + ? checker.getShorthandAssignmentValueSymbol(node.parent) + : checker.getSymbolAtLocation(node); + if (symbol !== undefined) parameters.delete(symbol); + } + compiler.forEachChild(node, visit); + }; + visit(fn); + return parameters; +} From 4ed71c27fa9d4ce38a53528407ea3242a630cfd6 Mon Sep 17 00:00:00 2001 From: Chase J <54216608+chajac@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:53:37 +0100 Subject: [PATCH 3/3] fix(flows): follow overloaded constructor keys --- .../accessorConstructors.test.ts | 20 +++++++++++++++++++ src/core/envVarAnalysis/accessors.ts | 8 ++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/core/envVarAnalysis/accessorConstructors.test.ts b/src/core/envVarAnalysis/accessorConstructors.test.ts index 9501dad40..37ef4bccf 100644 --- a/src/core/envVarAnalysis/accessorConstructors.test.ts +++ b/src/core/envVarAnalysis/accessorConstructors.test.ts @@ -28,6 +28,26 @@ describe("constructor accessor keys", () => { } }); + it("forwards keys to an overloaded constructor", async () => { + const result = await analyse({ + [flow]: `class Reader { + field = process.env.FIELD; + constructor(key: string); + constructor(key: string) { console.log(process.env[key]); } + } + function read(key: string) { return new Reader(key); } + export default () => read("TOKEN");`, + }); + try { + expect(result.byFlow.get(flow)).toEqual({ + names: ["FIELD", "TOKEN"], + mayBeIncomplete: false, + }); + } finally { + await result.cleanup(); + } + }); + it("resolves every constructor key slot", async () => { const result = await analyse({ [flow]: `class Reader { diff --git a/src/core/envVarAnalysis/accessors.ts b/src/core/envVarAnalysis/accessors.ts index 543852667..854a139e6 100644 --- a/src/core/envVarAnalysis/accessors.ts +++ b/src/core/envVarAnalysis/accessors.ts @@ -3,6 +3,7 @@ import type ts from "typescript"; import { calledDeclaration } from "./callableResolution.js"; import { isProcessEnv, isReadAccess } from "./envReads.js"; import { stableParameterSlots } from "./stableParameters.js"; +import { accessorDeclaration, constructedClass } from "./executionUnits.js"; import { functionExecution, isFunctionLike, @@ -53,8 +54,11 @@ export function findEnvAccessors( } if (!compiler.isCallExpression(node) && !compiler.isNewExpression(node)) return; - const callee = calledDeclaration(compiler, checker, node); - if (callee === undefined) return; + const declaration = + constructedClass(compiler, checker, node) ?? + calledDeclaration(compiler, checker, node); + if (declaration === undefined) return; + const callee = accessorDeclaration(compiler, checker, declaration); const argumentSlots = new Map(); node.arguments?.forEach((argument, argumentSlot) => { const slot = parameterSlot(argument);