Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions src/core/envVarAnalysis/envReads.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, expect, it } from "bun:test";
import ts from "typescript";

import { readEnvVarsFrom } from "./envReads.js";

function reads(source: string): { names: string[]; dynamic: boolean } {
const file = ts.createSourceFile(
"a.flow.ts",
source,
ts.ScriptTarget.Latest,
true,
);
const names = new Set<string>();
let dynamic = false;
const visit = (node: ts.Node): void => {
const result = readEnvVarsFrom(ts, node);
for (const name of result.names) names.add(name);
dynamic ||= result.dynamic;
ts.forEachChild(node, visit);
};
visit(file);
return { names: [...names].sort(), dynamic };
}

describe("environment read syntax", () => {
it("reads property, bracket, template, and destructured names", () => {
expect(
reads(
'process.env.DIRECT; process.env["BRACKET"]; process.env[`TEMPLATE`]; const { BOUND: value } = process.env;',
),
).toEqual({
names: ["BOUND", "BRACKET", "DIRECT", "TEMPLATE"],
dynamic: false,
});
});

it("excludes write-only assignments and deletes while retaining compound reads", () => {
expect(
reads(`export default () => {
process.env.WRITTEN = "generated";
process.env["BRACKET_WRITTEN"] = "generated";
delete process.env.DELETED;
delete process.env["BRACKET_DELETED"];
process.env.UPDATED += "suffix";
process.env.FALLBACK ??= "fallback";
process.env.COPIED = process.env.READ;
({ token: process.env.DESTRUCTURED } = { token: "generated" });
[process.env.ARRAY_WRITTEN] = ["generated"];
};`),
).toEqual({
names: ["FALLBACK", "READ", "UPDATED"],
dynamic: false,
});
});

it("excludes loop write targets while retaining iterable reads", () => {
expect(
reads(`for (process.env.TARGET of [process.env.VALUE]) {}
for (process.env["KEY"] in { [process.env.SOURCE]: true }) {}
for ({ key: process.env.OBJECT } of rows) {}
for ([process.env.ARRAY] of rows) {}`),
).toEqual({ names: ["SOURCE", "VALUE"], dynamic: false });
});

it("accepts static names containing template markers", () => {
expect(
reads('process.env["TOKEN${SUFFIX}"]; process.env[`LITERAL\\${KEY}`];'),
).toEqual({ names: ["LITERAL${KEY}", "TOKEN${SUFFIX}"], dynamic: false });
});

it.each([
"const env = process.env; env.TOKEN;",
"use(process.env);",
"({ ...process.env });",
"Object.keys(process.env);",
])("flags an unhandled environment object read: %s", (source) => {
expect(reads(source)).toEqual({ names: [], dynamic: true });
});

it("excludes writes to the environment object", () => {
expect(
reads(`process.env = {}; delete process.env;
for (process.env of objects) {}`),
).toEqual({ names: [], dynamic: false });
});

it("reads quoted and computed literal destructuring keys", () => {
expect(
reads(`const { "TOKEN": token, ["USER"]: user } = process.env;`),
).toEqual({
names: ["TOKEN", "USER"],
dynamic: false,
});
});

it.each([
`const { [key]: value } = process.env;`,
`process.env[key];`,
"process.env[`${key}_EMAIL`];",
`const { ...rest } = process.env;`,
])("flags a dynamic read: %s", (source) => {
expect(reads(source)).toEqual({ names: [], dynamic: true });
});

it("retains known names alongside uncertainty", () => {
expect(reads(`const { KNOWN, ...rest } = process.env;`)).toEqual({
names: ["KNOWN"],
dynamic: true,
});
});

it("does not read a name out of a comment", () => {
expect(
reads(
`// process.env.COMMENT\n/** process.env.JSDOC */\nprocess.env.REAL;`,
),
).toEqual({
names: ["REAL"],
dynamic: false,
});
});
});
104 changes: 104 additions & 0 deletions src/core/envVarAnalysis/envReads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type ts from "typescript";

import type { EnvReads } from "./types.js";

function isProcessEnv(compiler: typeof ts, node: ts.Node): boolean {
return (
compiler.isPropertyAccessExpression(node) &&
node.name.text === "env" &&
compiler.isIdentifier(node.expression) &&
node.expression.text === "process"
);
}

function isReadAccess(compiler: typeof ts, node: ts.Node): boolean {
let target = node;
while (
compiler.isParenthesizedExpression(target.parent) ||
compiler.isAsExpression(target.parent) ||
compiler.isNonNullExpression(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.isDeleteExpression(parent) &&
!(
compiler.isBinaryExpression(parent) &&
parent.left === target &&
parent.operatorToken.kind === compiler.SyntaxKind.EqualsToken
) &&
!(
(compiler.isForInStatement(parent) ||
compiler.isForOfStatement(parent)) &&
parent.initializer === target
)
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/** Reads on one visited syntax node; execution scope is controlled by the caller. */
export function readEnvVarsFrom(compiler: typeof ts, node: ts.Node): EnvReads {
const names = new Set<string>();
let dynamic = false;
if (
compiler.isPropertyAccessExpression(node) &&
isProcessEnv(compiler, node.expression) &&
isReadAccess(compiler, node)
) {
names.add(node.name.text);
}
if (
compiler.isElementAccessExpression(node) &&
isProcessEnv(compiler, node.expression) &&
isReadAccess(compiler, node)
) {
const argument = node.argumentExpression;
if (compiler.isStringLiteralLike(argument)) {
names.add(argument.text);
} else {
dynamic = true;
}
}
if (
compiler.isVariableDeclaration(node) &&
node.initializer !== undefined &&
isProcessEnv(compiler, node.initializer) &&
compiler.isObjectBindingPattern(node.name)
) {
for (const element of node.name.elements) {
if (element.dotDotDotToken !== undefined) {
dynamic = true;
continue;
}
const key = element.propertyName ?? element.name;
if (compiler.isIdentifier(key) || compiler.isStringLiteralLike(key)) {
names.add(key.text);
} else if (
compiler.isComputedPropertyName(key) &&
compiler.isStringLiteralLike(key.expression)
) {
names.add(key.expression.text);
} else {
dynamic = true;
}
}
}
if (isProcessEnv(compiler, node) && isReadAccess(compiler, node)) {
const parent = node.parent;
const handled =
((compiler.isPropertyAccessExpression(parent) ||
compiler.isElementAccessExpression(parent)) &&
parent.expression === node) ||
(compiler.isVariableDeclaration(parent) &&
parent.initializer === node &&
compiler.isObjectBindingPattern(parent.name));
if (!handled) dynamic = true;
}
return { names, dynamic };
}
5 changes: 5 additions & 0 deletions src/core/envVarAnalysis/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** Known reads and uncertainty for an executable unit. */
export type EnvReads = {
names: Set<string>;
dynamic: boolean;
};
9 changes: 9 additions & 0 deletions src/core/flowMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,12 @@ export type PeekFlowMetaFn = (filePath: string) => Promise<FlowCallMeta>;
export function extractFlowMeta(source: string): FlowCallMeta {
return parseFlowCall(source);
}

const flowExtensions = [".flow.ts", ".flow.js"];
const sourceExtensions = [".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"];

export const isFlowFile = (name: string): boolean =>
flowExtensions.some((extension) => name.endsWith(extension));

export const isSourceFile = (name: string): boolean =>
sourceExtensions.some((extension) => name.endsWith(extension));
30 changes: 4 additions & 26 deletions src/domains/flows/pull/applyTeamStorageRewrite.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,17 @@
import { join, relative } from "node:path";
import { relative } from "node:path";

import { isFlowFile, isSourceFile } from "~/core/flowMeta.js";
import { makeDefaultFs } from "~/shell/fs.js";
import type { Fs } from "~/shell/fs.js";
import { walkFiles } from "~/shell/walkFiles.js";

import { rewriteTeamStorage } from "./rewriteTeamStorage.js";

const sourceExtensions = [".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"];
const flowExtensions = [".flow.ts", ".flow.js"];

function isSourceFile(name: string): boolean {
return sourceExtensions.some((ext) => name.endsWith(ext));
}

function isFlowFile(name: string): boolean {
return flowExtensions.some((ext) => name.endsWith(ext));
}

async function walk(dir: string, out: string[], fs: Fs): Promise<void> {
const entries = await fs.readdirWithTypes(dir);
for (const e of entries) {
const abs = join(dir, e.name);
if (e.isDirectory()) {
await walk(abs, out, fs);
} else if (e.isFile() && isSourceFile(e.name)) {
out.push(abs);
}
}
}

export async function applyTeamStorageRewrite(
rootDir: string,
fs: Fs = makeDefaultFs(),
): Promise<{ flowsWithTeamStorageRefs: string[] }> {
const files: string[] = [];
await walk(rootDir, files, fs);
const files = await walkFiles(rootDir, isSourceFile, fs);
const results = await Promise.all(
files.map(async (file): Promise<string | undefined> => {
const source = await fs.readFile(file);
Expand Down
38 changes: 10 additions & 28 deletions src/domains/flows/pull/bundle.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { join, relative } from "node:path";

import { isFlowFile } from "~/core/flowMeta.js";
import { walkFiles } from "~/shell/walkFiles.js";

import { toPosix } from "~/core/repoRelativePath.js";

import { hashFile } from "~/shell/manifest/io.js";
Expand Down Expand Up @@ -48,8 +51,6 @@ function extractQawolfCommitSha(
return /-([0-9a-f]{40})$/i.exec(wrapperName)?.[1];
}

const flowExtensions = [".flow.ts", ".flow.js"];

/**
* Tags fetched for an env at pull time, keyed by repo-relative flow path.
* Undefined when the fetch did not happen or failed.
Expand All @@ -74,7 +75,7 @@ export async function buildManifest(
},
fs: Fs = makeDefaultFs(),
): Promise<Manifest> {
const flowPaths = await walkForFlows(args.bundleDir, fs);
const flowPaths = await flowPathsIn(args.bundleDir, fs);
const flows = await Promise.all(
flowPaths.map(async (rel) => ({
// Stored posix so a manifest written on one platform resolves on
Expand Down Expand Up @@ -102,30 +103,11 @@ export async function buildManifest(
};
}

async function walkForFlows(root: string, fs: Fs): Promise<string[]> {
const out: string[] = [];
await walk(root, root, out, fs);
return out.sort();
}

async function walk(
current: string,
root: string,
out: string[],
fs: Fs,
): Promise<void> {
const entries = await fs.readdirWithTypes(current);
for (const e of entries) {
const abs = join(current, e.name);
if (e.isDirectory()) {
await walk(abs, root, out, fs);
} else if (
e.isFile() &&
flowExtensions.some((ext) => e.name.endsWith(ext))
) {
out.push(relative(root, abs));
}
}
// Flow files under `root`, relative to it and sorted, so the manifest lists
// them in the same order on every pull.
async function flowPathsIn(root: string, fs: Fs): Promise<string[]> {
const found = await walkFiles(root, isFlowFile, fs);
return found.map((path) => toPosix(relative(root, path))).sort();
}

// Samples the mtime of any flow file in the bundle. GitHub-archive bundles
Expand All @@ -136,7 +118,7 @@ export async function sampleQawolfCommittedAt(
bundleDir: string,
fs: Fs = makeDefaultFs(),
): Promise<string | undefined> {
const flowPaths = await walkForFlows(bundleDir, fs);
const flowPaths = await flowPathsIn(bundleDir, fs);
const sample = flowPaths[0];
if (!sample) return undefined;
return (await fs.stat(join(bundleDir, sample))).mtime.toISOString();
Expand Down
Loading
Loading