From 24675dda868ce6da555241e714bb46d79745c8c1 Mon Sep 17 00:00:00 2001 From: rsnetworkinginc Date: Sat, 25 Jul 2026 18:55:37 +0300 Subject: [PATCH] fix(scripts): detect globalThis-cast and severity-threshold env reads The selfhost-env-reference generator only recognized .env accesses whose base is a bare process/c identifier, and helper calls carrying an env var name in a single fixed argument slot. src/selfhost/posthog.ts reads POSTHOG_SERVER_NAME through (globalThis as unknown as {...}).process?.env and POSTHOG_MIN_SEVERITY/POSTHOG_REPO_MIN_SEVERITY as string-literal arguments 2 and 3 of resolveSeverityThreshold(), so all three real operator-facing vars were silently absent from the generated reference. - isEnvContainer now accepts a .env base that resolves to globalThis.process through casts (new isProcessExpression), not just a bare process/c identifier. - ENV_NAME_LITERAL_ARG_HELPERS entries now carry a list of argument indexes, and resolveSeverityThreshold is registered at indexes 2 and 3. - Regenerated apps/loopover-ui/src/lib/selfhost-env-reference.ts: adds POSTHOG_MIN_SEVERITY, POSTHOG_REPO_MIN_SEVERITY, POSTHOG_SERVER_NAME (src/selfhost/posthog.ts) plus PAGERDUTY_MIN_SEVERITY and PAGERDUTY_REPO_MIN_SEVERITY (src/services/notify-pagerduty.ts:103, the same resolveSeverityThreshold blind spot in an already-scanned root, surfaced by the same generalized fix). - Four new fixture tests (match + deliberate non-match for each new branch) in test/unit/selfhost-env-reference-script.test.ts. --- .../src/lib/selfhost-env-reference.ts | 25 ++++++++++ scripts/gen-selfhost-env-reference.ts | 40 ++++++++++----- .../selfhost-env-reference-script.test.ts | 49 +++++++++++++++++++ 3 files changed, 102 insertions(+), 12 deletions(-) diff --git a/apps/loopover-ui/src/lib/selfhost-env-reference.ts b/apps/loopover-ui/src/lib/selfhost-env-reference.ts index cff7ff3ed9..0c0684aae3 100644 --- a/apps/loopover-ui/src/lib/selfhost-env-reference.ts +++ b/apps/loopover-ui/src/lib/selfhost-env-reference.ts @@ -413,6 +413,14 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ name: "PAGERDUTY_COOLDOWN_MINUTES", firstReference: "src/services/notify-pagerduty.ts", }, + { + name: "PAGERDUTY_MIN_SEVERITY", + firstReference: "src/services/notify-pagerduty.ts", + }, + { + name: "PAGERDUTY_REPO_MIN_SEVERITY", + firstReference: "src/services/notify-pagerduty.ts", + }, { name: "PAGERDUTY_ROUTING_KEY", firstReference: "src/services/notify-pagerduty.ts", @@ -441,10 +449,22 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ name: "POSTHOG_HOST", firstReference: "src/selfhost/otel.ts", }, + { + name: "POSTHOG_MIN_SEVERITY", + firstReference: "src/selfhost/posthog.ts", + }, { name: "POSTHOG_RELEASE", firstReference: "src/selfhost/otel.ts", }, + { + name: "POSTHOG_REPO_MIN_SEVERITY", + firstReference: "src/selfhost/posthog.ts", + }, + { + name: "POSTHOG_SERVER_NAME", + firstReference: "src/selfhost/posthog.ts", + }, { name: "PUBLIC_API_ORIGIN", firstReference: "src/selfhost/preflight.ts", @@ -648,6 +668,8 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `OTEL_TRACES_SAMPLER` | `src/selfhost/otel.ts` |", "| `OTEL_TRACES_SAMPLER_ARG` | `src/selfhost/otel.ts` |", "| `PAGERDUTY_COOLDOWN_MINUTES` | `src/services/notify-pagerduty.ts` |", + "| `PAGERDUTY_MIN_SEVERITY` | `src/services/notify-pagerduty.ts` |", + "| `PAGERDUTY_REPO_MIN_SEVERITY` | `src/services/notify-pagerduty.ts` |", "| `PAGERDUTY_ROUTING_KEY` | `src/services/notify-pagerduty.ts` |", "| `PGPOOL_MAX` | `src/selfhost/queue-common.ts` |", "| `PGVECTOR_ENABLED` | `src/server.ts` |", @@ -655,7 +677,10 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `POSTHOG_API_KEY` | `src/selfhost/otel.ts` |", "| `POSTHOG_ENVIRONMENT` | `src/selfhost/otel.ts` |", "| `POSTHOG_HOST` | `src/selfhost/otel.ts` |", + "| `POSTHOG_MIN_SEVERITY` | `src/selfhost/posthog.ts` |", "| `POSTHOG_RELEASE` | `src/selfhost/otel.ts` |", + "| `POSTHOG_REPO_MIN_SEVERITY` | `src/selfhost/posthog.ts` |", + "| `POSTHOG_SERVER_NAME` | `src/selfhost/posthog.ts` |", "| `PUBLIC_API_ORIGIN` | `src/selfhost/preflight.ts` |", "| `PUBLIC_ORIGIN_ACKNOWLEDGED` | `src/server.ts` |", "| `PUBLIC_SITE_ORIGIN` | `src/server.ts` |", diff --git a/scripts/gen-selfhost-env-reference.ts b/scripts/gen-selfhost-env-reference.ts index b7ef99573e..9720124655 100644 --- a/scripts/gen-selfhost-env-reference.ts +++ b/scripts/gen-selfhost-env-reference.ts @@ -91,8 +91,10 @@ function collectEnvReads(source: string, fileName: string): EnvRead[] { } else if (ts.isCallExpression(node) && isProcessEnvNameHelperCall(node)) { addRead((node.arguments[0] as ts.StringLiteralLike).text); } else if (ts.isCallExpression(node) && isEnvNameLiteralArgHelperCall(node)) { - const argIndex = ENV_NAME_LITERAL_ARG_HELPERS.get((node.expression as ts.Identifier).text)!; - addRead((node.arguments[argIndex] as ts.StringLiteralLike).text); + for (const argIndex of ENV_NAME_LITERAL_ARG_HELPERS.get((node.expression as ts.Identifier).text)!) { + const arg = node.arguments[argIndex]; + if (arg && ts.isStringLiteralLike(arg)) addRead(arg.text); + } } ts.forEachChild(node, visit); }; @@ -115,12 +117,18 @@ function isStaticEnvHelperCall(node: ts.CallExpression): boolean { // isStaticEnvHelperCall above (envString) because these take the var NAME as arg[0], not arg[1] after a // container. const PROCESS_ENV_NAME_HELPERS = new Set(["parsePositiveIntEnv"]); -const ENV_NAME_LITERAL_ARG_HELPERS = new Map([ - ["resolveLocalStoreDbPath", 1], +const ENV_NAME_LITERAL_ARG_HELPERS = new Map([ + ["resolveLocalStoreDbPath", [1]], // createCliProvider(command, modelEnvKey, options, env) (packages/loopover-engine/src/miner/driver-factory.ts) // reads env[modelEnvKey] -- a computed access AST-invisible without this, since modelEnvKey is a parameter, // not a literal at the read site. The literal var name is only visible at the CALL site (arg index 1). (#6994) - ["createCliProvider", 1], + ["createCliProvider", [1]], + // resolveSeverityThreshold(env, repoFullName, globalVarName, repoMapVarName, fallback?) (src/services/ + // severity-threshold.ts) reads env[globalVarName] and env[repoMapVarName] internally -- computed accesses + // AST-invisible at the read site, same as createCliProvider above. Both literal var names are only visible + // at the CALL site (arg indexes 2 and 3), e.g. POSTHOG_MIN_SEVERITY/POSTHOG_REPO_MIN_SEVERITY in + // src/selfhost/posthog.ts's resolvePostHogMinSeverity. (#8627) + ["resolveSeverityThreshold", [2, 3]], ]); function isProcessEnvNameHelperCall(node: ts.CallExpression): boolean { @@ -129,8 +137,8 @@ function isProcessEnvNameHelperCall(node: ts.CallExpression): boolean { function isEnvNameLiteralArgHelperCall(node: ts.CallExpression): boolean { if (!ts.isIdentifier(node.expression)) return false; - const argIndex = ENV_NAME_LITERAL_ARG_HELPERS.get(node.expression.text); - return argIndex !== undefined && node.arguments.length > argIndex && ts.isStringLiteralLike(node.arguments[argIndex]!); + const argIndexes = ENV_NAME_LITERAL_ARG_HELPERS.get(node.expression.text); + return argIndexes !== undefined && argIndexes.some((argIndex) => node.arguments.length > argIndex && ts.isStringLiteralLike(node.arguments[argIndex]!)); } function bindingElementName(element: ts.BindingElement): string | null { @@ -150,11 +158,19 @@ function unwrapEnvExpression(node: ts.Expression): ts.Expression { function isEnvContainer(rawNode: ts.Expression): boolean { const node = unwrapEnvExpression(rawNode); if (ts.isIdentifier(node)) return node.text === "env"; - return ( - ts.isPropertyAccessExpression(node) && - node.name.text === "env" && - ((ts.isIdentifier(node.expression) && (node.expression.text === "process" || node.expression.text === "c")) || isEnvContainer(node.expression)) - ); + return ts.isPropertyAccessExpression(node) && node.name.text === "env" && (isProcessExpression(node.expression) || isEnvContainer(node.expression)); +} + +// The base of a `.env` access: a bare `process`/`c` identifier, or `globalThis.process` reached through a cast -- +// src/selfhost/posthog.ts reads `(globalThis as unknown as {process?: {env?: ...}}).process?.env?.POSTHOG_SERVER_NAME`, +// where the base of `.env` is a property access on a cast expression rather than an identifier, so the +// identifier-only check silently dropped POSTHOG_SERVER_NAME from the generated reference (#8627). +function isProcessExpression(rawNode: ts.Expression): boolean { + const node = unwrapEnvExpression(rawNode); + if (ts.isIdentifier(node)) return node.text === "process" || node.text === "c"; + if (!ts.isPropertyAccessExpression(node) || node.name.text !== "process") return false; + const base = unwrapEnvExpression(node.expression); + return ts.isIdentifier(base) && base.text === "globalThis"; } function scriptKindFor(fileName: string): ts.ScriptKind { diff --git a/test/unit/selfhost-env-reference-script.test.ts b/test/unit/selfhost-env-reference-script.test.ts index 6423c42be7..015bf49699 100644 --- a/test/unit/selfhost-env-reference-script.test.ts +++ b/test/unit/selfhost-env-reference-script.test.ts @@ -162,6 +162,55 @@ describe("gen-selfhost-env-reference (#2081)", () => { }); }); +describe("PostHog self-host env reads invisible to the visitor (#8627)", () => { + function rootWith(source: string): string { + const root = mkdtempSync(join(tmpdir(), "gt-env-reference-8627-")); + mkdirSync(join(root, "src", "selfhost"), { recursive: true }); + writeFileSync(join(root, "src", "selfhost", "posthog.ts"), source); + return root; + } + + function collectedNames(root: string): string[] { + return collectSelfHostEnvVars({ rootDir: root }).map((row) => row.name); + } + + it("recognizes a .env property access whose base is globalThis.process behind an `as unknown as` cast", () => { + // The exact shape src/selfhost/posthog.ts uses to read POSTHOG_SERVER_NAME: the base of `.env` is a + // property access on a cast expression, not a bare `process` identifier. + const root = rootWith( + "const serverName = nonBlank((globalThis as unknown as { process?: { env?: Record } }).process?.env?.POSTHOG_SERVER_NAME) ?? hostname();\n", + ); + expect(collectSelfHostEnvVars({ rootDir: root })).toEqual([{ name: "POSTHOG_SERVER_NAME", firstReference: "src/selfhost/posthog.ts" }]); + }); + + it("does NOT match the cast shape when the .process base is not globalThis", () => { + // Same cast-and-property-access silhouette, different base identifier: the new branch must not treat any + // `.process.env` as the ambient process env. + const root = rootWith( + "const serverName = ((someScope as unknown as { process?: { env?: Record } }).process?.env?.NOT_AMBIENT_PROCESS_READ);\n", + ); + expect(collectedNames(root)).toEqual([]); + }); + + it("captures both env var name literals of a resolveSeverityThreshold call", () => { + // The exact call shape of src/selfhost/posthog.ts's resolvePostHogMinSeverity: env var names appear only + // as string-literal arguments 2 and 3 -- the reads inside the helper are computed and AST-invisible. + const root = rootWith( + 'const severity = resolveSeverityThreshold(processEnv as unknown as Env, repoFullName, "POSTHOG_MIN_SEVERITY", "POSTHOG_REPO_MIN_SEVERITY");\n', + ); + expect(collectedNames(root)).toEqual(["POSTHOG_MIN_SEVERITY", "POSTHOG_REPO_MIN_SEVERITY"]); + }); + + it("does NOT capture name-shaped literals passed to a call outside the helper allowlist", () => { + // A same-arity call to a different function must stay invisible: recognition is keyed on the helper name, + // not on any string literal that merely looks like an env var name. + const root = rootWith( + 'const severity = resolveSeverityCeiling(processEnv as unknown as Env, repoFullName, "NOT_CAPTURED_GLOBAL", "NOT_CAPTURED_REPO_MAP");\n', + ); + expect(collectedNames(root)).toEqual([]); + }); +}); + describe("AI review-pipeline self-host env vars (#6993)", () => { it("scans the AI review source roots so their self-host AI_* vars are collected", () => { // Against the REAL repo with the default (now-extended) source roots: the four AI review-pipeline knobs are