diff --git a/.bumpy/process-env-augmentation-conflict.md b/.bumpy/process-env-augmentation-conflict.md new file mode 100644 index 000000000..c33bd873f --- /dev/null +++ b/.bumpy/process-env-augmentation-conflict.md @@ -0,0 +1,5 @@ +--- +varlock: patch +--- + +Skip the process.env type augmentation when another .d.ts (e.g. wrangler's worker-configuration.d.ts) already declares NodeJS.ProcessEnv, which previously caused a TS2320 conflict. Set processEnv=strict on @generateTsTypes to override. diff --git a/packages/varlock-website/src/content/docs/integrations/cloudflare.mdx b/packages/varlock-website/src/content/docs/integrations/cloudflare.mdx index 2adf03cc6..7ea1be896 100644 --- a/packages/varlock-website/src/content/docs/integrations/cloudflare.mdx +++ b/packages/varlock-website/src/content/docs/integrations/cloudflare.mdx @@ -254,6 +254,18 @@ Some framework templates split their TypeScript setup into multiple referenced c This types the `ENV` object. For the native `env` parameter in your fetch handler, use [`varlock-wrangler types`](#varlock-wrangler-types) to generate an `Env` interface that includes your varlock-managed vars alongside your other Cloudflare bindings. +### `process.env` is typed by wrangler, not varlock + +`wrangler types` writes its own `NodeJS.ProcessEnv` augmentation into `worker-configuration.d.ts`, covering every var and secret it knows about. TypeScript merges all declarations of that interface and requires every shared key to be identical across them, so varlock's augmentation and wrangler's cannot both apply: a single optional item (`FOO?: string` against wrangler's `FOO: string`) fails with `TS2320`, as do enums and booleans, which varlock types as literal unions. + +When varlock sees an existing `NodeJS.ProcessEnv` declaration next to your schema, it defers and skips its own, noting why at the top of the generated file. Nothing is lost if you generate types with [`varlock-wrangler types`](#varlock-wrangler-types): it feeds your schema's keys to wrangler, so all of them are typed on `process.env` as strings. Use `ENV` where you want the coerced types (enums, booleans, numbers) and the docs from your schema. + +Setting `processEnv=strict` on [`@generateTsTypes`](/reference/root-decorators/#generatetstypes) opts back in, but only do that if nothing else is declaring `NodeJS.ProcessEnv`: with wrangler's block present, the two conflict and TypeScript reports `TS2320`. + +:::caution[With `skipLibCheck: true` the conflict is silent] +Most Worker templates enable `skipLibCheck`, which suppresses `TS2320` in `.d.ts` files entirely. The two declarations still clash, and whichever file TypeScript reaches first wins, so a key can silently take wrangler's literal type from `wrangler.jsonc` instead of your schema's. Deferring avoids that as well. +::: + ---- ## Log redaction and leak prevention diff --git a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx index 227d9f7f2..57aeea6ff 100644 --- a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx @@ -654,6 +654,8 @@ TypeScript type declarations. Makes `import { ENV } from 'varlock/env'` typed an - `local`: export a package-local typed `ENV` from the generated file (with **no** global augmentation, since `processEnv`/`importMetaEnv` default to `none` too, so nothing merges across packages). Use this in monorepos where multiple packages have different schemas. Requires a `.ts` output path (not `.d.ts`, since it contains a runtime re-export), and you import from the generated file (e.g. `import { ENV } from './env'`). - `none`: emit only the type definitions, no `ENV` binding. - `processEnv`: how `process.env` is typed: `strict`, `loose`, or `none` (defaults to `strict`; to `none` when `exposeEnv=local`, or when [`@disableProcessEnvInjection`](#disableprocessenvinjection) is set, since values aren't put on `process.env` then). Note that `@types/node` declares a base string index signature that can't be removed, so extra keys remain allowed on `process.env` regardless. + + It also defaults to `none` when another `.d.ts` sitting next to your schema (or next to the generated file) already declares `NodeJS.ProcessEnv`. The common case is `worker-configuration.d.ts`, written by [`wrangler types`](/integrations/cloudflare/#type-safety-and-intellisense). TypeScript merges every declaration of that one interface and requires each shared key to be identical, so two of them can't coexist: an optional item alone (`FOO?: string` vs `FOO: string`) is enough to fail with `TS2320`. The generated file names the file that triggered the skip. Set `processEnv=strict` to emit ours anyway. - `importMetaEnv`: how `import.meta.env` is typed: `strict`, `loose`, or `none` (defaults to `strict`; to `none` when `exposeEnv=local`). ```env-spec diff --git a/packages/varlock/src/env-graph/index.ts b/packages/varlock/src/env-graph/index.ts index c3eb6e878..637f1f3c7 100644 --- a/packages/varlock/src/env-graph/index.ts +++ b/packages/varlock/src/env-graph/index.ts @@ -17,6 +17,7 @@ export { export { builtInCodeGenerators, collectTypeGenItems, + findConflictingProcessEnvAugmentation, generateCsharpEnvSrc, generateGoEnvSrc, generateJavaEnvSrc, diff --git a/packages/varlock/src/env-graph/lib/type-generation/banners.ts b/packages/varlock/src/env-graph/lib/type-generation/banners.ts index c2f9b385b..c2c2f5a7c 100644 --- a/packages/varlock/src/env-graph/lib/type-generation/banners.ts +++ b/packages/varlock/src/env-graph/lib/type-generation/banners.ts @@ -1,8 +1,11 @@ export type TypeGenCommentStyle = '//' | '#'; +/** stable substring of the banner, used to recognize our own generated files */ +export const AUTOGENERATED_FILE_MARKER = 'THIS IS AN AUTOGENERATED FILE'; + const AUTOGENERATED_BANNER_LINES = [ '🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑', - '🛑 THIS IS AN AUTOGENERATED FILE - DO NOT EDIT DIRECTLY 🛑', + `🛑 ${AUTOGENERATED_FILE_MARKER} - DO NOT EDIT DIRECTLY 🛑`, '🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑', ]; diff --git a/packages/varlock/src/env-graph/lib/type-generation/code-generators.ts b/packages/varlock/src/env-graph/lib/type-generation/code-generators.ts index 5cd4c48d2..d79654791 100644 --- a/packages/varlock/src/env-graph/lib/type-generation/code-generators.ts +++ b/packages/varlock/src/env-graph/lib/type-generation/code-generators.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import type { EnvGraph } from '../env-graph'; import type { TypeGenItemInfo } from '../config-item'; import { isVarlockReservedKey } from '../reserved-vars'; @@ -8,6 +9,7 @@ import { generatePhpEnvSrc } from './emitters/php'; import { generatePythonEnvSrc } from './emitters/python'; import { generateRustEnvSrc } from './emitters/rust'; import { generateTsTypesSrc } from './emitters/ts'; +import { findConflictingProcessEnvAugmentation } from './detect-process-env-augmentation'; import { type ResolvedFieldType } from './shared'; /** Everything a code generator needs to produce a single output file. */ @@ -67,7 +69,7 @@ const LANG_TO_DECORATOR: Record = { csharp: 'generateCsharpEnv', }; -function generateTsFile(ctx: CodeGenContext): Promise { +async function generateTsFile(ctx: CodeGenContext): Promise { // local mode emits a runtime re-export (TypeScript syntax) — a `.d.ts` has no runtime binding, // and anything else (`.js`, ...) would not parse, so require a real `.ts` module if (ctx.options.exposeEnv === 'local' @@ -83,6 +85,25 @@ function generateTsFile(ctx: CodeGenContext): Promise { if (options.processEnv === undefined && ctx.graph.isProcessEnvInjectionDisabled) { options.processEnv = 'none'; } + // `NodeJS.ProcessEnv` is a single global interface, and merged declarations of it must have + // identical members for every shared key, so ours cannot coexist with another tool's (see + // detect-process-env-augmentation). When something else already declares it, defer rather than + // emit a guaranteed TS2320 - an explicit `processEnv=` still wins. `exposeEnv=local` already + // defaults to `none`, so there is nothing to defer there. + if (options.processEnv === undefined && options.exposeEnv !== 'local') { + const conflictPath = await findConflictingProcessEnvAugmentation({ + dirs: [ctx.sourceDir, path.dirname(ctx.outputPath)], + outputPath: ctx.outputPath, + }); + if (conflictPath) { + options.processEnv = 'none'; + options.processEnvSkipNote = [ + `NOTE: the \`process.env\` augmentation was skipped because ${path.basename(conflictPath)}`, + 'already declares NodeJS.ProcessEnv, and two declarations of it cannot both apply.', + 'Use `ENV` for the full types, or set `processEnv=strict` on @generateTsTypes to override.', + ].join('\n'); + } + } // `@injectUndefinedAsEmpty` means unset items land on process.env as empty strings, so the // process.env augmentation drops its optionality (graph-level flag, not a decorator arg) options.injectUndefinedAsEmpty = ctx.graph.injectUndefinedAsEmpty; diff --git a/packages/varlock/src/env-graph/lib/type-generation/detect-process-env-augmentation.ts b/packages/varlock/src/env-graph/lib/type-generation/detect-process-env-augmentation.ts new file mode 100644 index 000000000..370fd44a4 --- /dev/null +++ b/packages/varlock/src/env-graph/lib/type-generation/detect-process-env-augmentation.ts @@ -0,0 +1,133 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { AUTOGENERATED_FILE_MARKER } from './banners'; + +/** + * `NodeJS.ProcessEnv` is a single global interface, so two declarations of it must have + * *identical* members for every shared key or TypeScript errors (TS2320 - "Interface + * 'ProcessEnv' cannot simultaneously extend types ... are not identical"). Our augmentation can + * never match another tool's: an optional item alone (`FOO?: string` vs `FOO: string`) is enough + * to conflict, as are enums and booleans, which we type as literal unions. + * + * The best known example is `wrangler types`, which writes a `NodeJS.ProcessEnv` augmentation + * into `worker-configuration.d.ts` covering every var and secret. Rather than detect Cloudflare + * (or any other platform - infra-as-code setups may not have a wrangler config at all), we look + * for the thing that actually collides: an existing ProcessEnv declaration. + * + * Note the failure bias. Missing a declaration is safe: we keep emitting what we emit today, and + * if nothing else declares ProcessEnv there was no conflict to avoid. Matching too eagerly would + * silently drop typing, so the generated file names the file we found. + */ + +/** + * Only the head of each file is scanned. These declarations sit at the top of generated files + * (wrangler writes env types first, then ~1MB of runtime types after a `// Begin runtime types` + * marker), and codegen runs on every load, so slurping whole files would be wasteful. + */ +const MAX_SCAN_BYTES = 64 * 1024; + +async function readFileHead(filePath: string, maxBytes = MAX_SCAN_BYTES): Promise { + let fileHandle; + try { + fileHandle = await fs.promises.open(filePath, 'r'); + const buffer = Buffer.alloc(maxBytes); + const { bytesRead } = await fileHandle.read(buffer, 0, maxBytes, 0); + return buffer.subarray(0, bytesRead).toString('utf-8'); + } catch { + // unreadable (deleted mid-scan, permissions, ...) - treat as "no declaration found" + return undefined; + } finally { + await fileHandle?.close().catch(() => undefined); + } +} + +// Comments and string/template literals are not declaration syntax, and either can spoof the +// match in both directions (prose that reads like a declaration, or a stray `}` that hides one). +// Both classes are alternatives of ONE regex, so a single left-to-right pass takes whichever +// opens first: stripping either class ahead of the other lets it be spoofed (`// don't` eating +// real code, or `type M = '//'` hiding the rest of a line). Replaced with a space, not removed, +// so neighbouring tokens can't fuse. +const COMMENTS_AND_LITERALS = new RegExp([ + /\/\*[\s\S]*?\*\//, // block comment + /\/\/[^\n]*/, // line comment + // the escape alternative takes any character, newline included, so a backslash-newline line + // continuation stays inside the literal instead of ending it and exposing its contents. Line + // endings are normalized before this runs, so `\\` + newline is always exactly two characters: + // spelling CRLF out here instead would give `\\` + CRLF two ways to match, which backtracks + // exponentially on an unterminated literal (js/redos). + /'(?:[^'\\\n]|\\[\s\S])*'/, // single-quoted + /"(?:[^"\\\n]|\\[\s\S])*"/, // double-quoted + /`(?:[^`\\]|\\[\s\S])*`/, // template literal +].map((r) => r.source).join('|'), 'g'); + +/** + * `interface ProcessEnv` opening a `namespace NodeJS` block. The two tokens must be adjacent + * (nothing but whitespace, modifiers, or `}`-free text between them), which is what every + * generator that writes one of these actually emits, ours included: + * + * declare namespace NodeJS { interface ProcessEnv extends ... {} } // wrangler + * declare global { namespace NodeJS { interface ProcessEnv ... } } // varlock + * + * Requiring adjacency is what keeps this honest without a parser. `[^}]` can't run past the end + * of the first member, so a `ProcessEnv` declared *after* the namespace closes can't match, and + * `\b` keeps `interface ProcessEnvExtra` out. + */ +const NODEJS_PROCESS_ENV = /\bnamespace\s+NodeJS\s*\{[^}]{0,400}?\binterface\s+ProcessEnv\b/; + +/** + * True when the source looks like it declares `NodeJS.ProcessEnv`. + * + * A heuristic on purpose. It is deliberately biased to miss rather than over-match, because the + * two failure directions are not symmetric: a miss just leaves today's behaviour in place, and + * the conflict it would have avoided shows up as a loud `TS2320` with a documented one-arg fix + * (`processEnv=none`), whereas a false positive silently drops typing that was fine. So anything + * it can't read confidently (a declaration that isn't the first member of its namespace, or one + * buried in an exotic literal the strip above doesn't recognize) simply doesn't match, and lands + * the user on the error and the documented fix. Matching by structure rather than by any one tool's output shape also keeps + * it from rotting when that output changes. + */ +function declaresProcessEnv(rawSrc: string): boolean { + // normalize line endings first so the patterns above only ever deal with `\n` + const src = rawSrc.replace(/\r\n?/g, '\n'); + return NODEJS_PROCESS_ENV.test(src.replace(COMMENTS_AND_LITERALS, ' ')); +} + +/** + * Look for a `.d.ts` alongside the schema (and alongside the generated file, when that lives + * elsewhere) that already augments `NodeJS.ProcessEnv`. + * + * Returns the absolute path of the first match, or undefined. Our own generated files are + * skipped - they carry the autogenerated banner, so a second `@generateTsTypes` writing into the + * same directory doesn't look like a foreign declaration. + */ +export async function findConflictingProcessEnvAugmentation(opts: { + /** directories to scan, non-recursively */ + dirs: Array; + /** absolute path of the file being generated, never treated as a conflict */ + outputPath: string; +}): Promise { + const scanned = new Set(); + for (const dir of opts.dirs) { + const resolvedDir = path.resolve(dir); + if (scanned.has(resolvedDir)) continue; + scanned.add(resolvedDir); + + let entries: Array; + try { + entries = await fs.promises.readdir(resolvedDir); + } catch { + continue; + } + + for (const entry of entries.sort()) { + if (!entry.endsWith('.d.ts')) continue; + const filePath = path.join(resolvedDir, entry); + if (filePath === path.resolve(opts.outputPath)) continue; + const src = await readFileHead(filePath); + if (!src) continue; + if (src.includes(AUTOGENERATED_FILE_MARKER)) continue; + if (declaresProcessEnv(src)) return filePath; + } + } + return undefined; +} diff --git a/packages/varlock/src/env-graph/lib/type-generation/emitters/ts.ts b/packages/varlock/src/env-graph/lib/type-generation/emitters/ts.ts index bad61cc51..9250a2bb4 100644 --- a/packages/varlock/src/env-graph/lib/type-generation/emitters/ts.ts +++ b/packages/varlock/src/env-graph/lib/type-generation/emitters/ts.ts @@ -169,6 +169,12 @@ export type TsGenOptions = { * through it, so a schema key may be absent there regardless of injection mode. */ injectUndefinedAsEmpty?: boolean; + /** + * Set when `processEnv` was defaulted to `none` because another `.d.ts` already declares + * `NodeJS.ProcessEnv` (not a decorator arg). Emitted as a comment in the generated file, so the + * skip is visible where someone would notice it, naming the file found and how to override. + */ + processEnvSkipNote?: string; }; // defaults preserve the historical output: globally augment `varlock/env`, and augment both globals @@ -181,6 +187,7 @@ const DEFAULT_TS_GEN_OPTIONS = { processEnv: 'strict', importMetaEnv: 'strict', injectUndefinedAsEmpty: false, + processEnvSkipNote: '', } satisfies Required; const TS_ENV_EXPOSURE_VALUES: ReadonlyArray = ['global', 'local', 'none']; @@ -207,6 +214,7 @@ function resolveTsGenOptions(options: Record = {}): Required, optio '/* eslint-disable */', ]; + if (opts.processEnvSkipNote) { + tsSrc.push('', ..._.map(opts.processEnvSkipNote.split('\n'), (line) => `// ${line}`)); + } + // `local` exposure re-exports the runtime ENV proxy, so we need to import it if (opts.exposeEnv === 'local') { tsSrc.push("import { ENV as _ENV } from 'varlock/env';", ''); diff --git a/packages/varlock/src/env-graph/lib/type-generation/index.ts b/packages/varlock/src/env-graph/lib/type-generation/index.ts index d09db6607..b0b034830 100644 --- a/packages/varlock/src/env-graph/lib/type-generation/index.ts +++ b/packages/varlock/src/env-graph/lib/type-generation/index.ts @@ -11,6 +11,7 @@ export { type CoercedType, type ResolvedFieldType, } from './shared'; +export { findConflictingProcessEnvAugmentation } from './detect-process-env-augmentation'; export { builtInCodeGenerators, collectTypeGenItems, diff --git a/packages/varlock/src/env-graph/test/type-generation.test.ts b/packages/varlock/src/env-graph/test/type-generation.test.ts index 26da35a71..83fa2edb0 100644 --- a/packages/varlock/src/env-graph/test/type-generation.test.ts +++ b/packages/varlock/src/env-graph/test/type-generation.test.ts @@ -1,5 +1,5 @@ import { - afterEach, describe, expect, test, vi, + afterEach, beforeEach, describe, expect, test, vi, } from 'vitest'; import outdent from 'outdent'; import path from 'node:path'; @@ -7,6 +7,7 @@ import path from 'node:path'; import { EnvGraph, DotEnvFileDataSource, collectTypeGenItems, + findConflictingProcessEnvAugmentation, generateTsTypesSrc, resolveFieldType, resolveFieldTypes, @@ -1428,4 +1429,198 @@ describe('type generation', () => { } }); }); + describe('existing NodeJS.ProcessEnv declarations', () => { + // `NodeJS.ProcessEnv` is one global interface, so a second declaration of it (wrangler writes + // one into worker-configuration.d.ts) conflicts with ours on any shared key that isn't + // *identical*, and an optional item alone is enough. We defer instead of emitting a guaranteed + // TS2320. See detect-process-env-augmentation.ts. + const FOREIGN_DTS = outdent` + declare namespace NodeJS { + interface ProcessEnv extends StringifyValues> {} + } + `; + + let tempDir: string; + let cwdSpy: ReturnType; + + beforeEach(async () => { + const fs = await import('node:fs'); + const os = await import('node:os'); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-process-env-augment-')); + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tempDir); + }); + afterEach(async () => { + cwdSpy.mockRestore(); + await import('node:fs').then((fs) => fs.promises.rm(tempDir, { recursive: true, force: true })); + }); + + async function writeFile(name: string, contents: string) { + const fs = await import('node:fs'); + await fs.promises.writeFile(path.join(tempDir, name), contents, 'utf-8'); + } + async function generate(decoratorArgs: string) { + const g = new EnvGraph(); + await g.setRootDataSource(new DotEnvFileDataSource('.env.schema', { + overrideContents: outdent` + # @defaultSensitive=false + # @generateTsTypes(${decoratorArgs}) + # --- + SOME_ITEM=val # @optional + `, + })); + await g.finishLoad(); + await g.runCodeGeneratorsIfNeeded(); + const fs = await import('node:fs'); + return fs.promises.readFile(path.join(tempDir, 'env.d.ts'), 'utf-8'); + } + + test('finds a foreign .d.ts that declares ProcessEnv', async () => { + await writeFile('worker-configuration.d.ts', FOREIGN_DTS); + const found = await findConflictingProcessEnvAugmentation({ + dirs: [tempDir], + outputPath: path.join(tempDir, 'env.d.ts'), + }); + expect(found).toBe(path.join(tempDir, 'worker-configuration.d.ts')); + }); + + test('ignores our own generated files, the output path, and non-.d.ts files', async () => { + // a second @generateTsTypes output in the same dir carries our banner, not a foreign decl + await writeFile('other-env.d.ts', await generate('path=env.d.ts')); + await writeFile('env.d.ts', FOREIGN_DTS); + await writeFile('globals.ts', FOREIGN_DTS); + const found = await findConflictingProcessEnvAugmentation({ + dirs: [tempDir], + outputPath: path.join(tempDir, 'env.d.ts'), + }); + expect(found).toBeUndefined(); + }); + + test.each([ + ['only one of the two names is present', 'declare namespace NodeJS { interface Process { foo: string } }'], + // both names appear, but ProcessEnv is not declared inside the NodeJS namespace + [ + 'ProcessEnv is declared outside the namespace', outdent` + declare namespace NodeJS { interface Process { foo: string } } + interface ProcessEnv { UNRELATED: string } + `, + ], + ['the interface name only starts with ProcessEnv', 'declare namespace NodeJS { interface ProcessEnvExtra { foo: string } }'], + // the match requires ProcessEnv to open the namespace, so a later member is a (fail-safe) miss + [ + 'ProcessEnv is not the first member of the namespace', outdent` + declare namespace NodeJS { + interface Process { foo: string } + interface ProcessEnv { BAR: string } + } + `, + ], + [ + 'the declaration is inside a comment', outdent` + // declare namespace NodeJS { interface ProcessEnv {} } + export {}; + `, + ], + // literal contents are not syntax either + ['the declaration text is inside a string literal', "declare namespace NodeJS { const marker: 'interface ProcessEnv'; }"], + // a backslash-newline continuation keeps the literal open across lines, either line ending + ['the declaration text is inside a continued string literal', "declare namespace NodeJS {\n type Marker = 'a\\\ninterface ProcessEnv b';\n}"], + ['the continued string literal uses CRLF', "declare namespace NodeJS {\r\n type Marker = 'a\\\r\ninterface ProcessEnv b';\r\n}"], + ])('not a conflict when %s', async (_label, contents) => { + await writeFile('other.d.ts', contents); + const found = await findConflictingProcessEnvAugmentation({ + dirs: [tempDir], + outputPath: path.join(tempDir, 'env.d.ts'), + }); + expect(found).toBeUndefined(); + }); + + // comments and literals have to be consumed in source order: stripping either class first + // lets the other be spoofed, hiding a real declaration that follows + test.each([ + // a line comment delimiter inside a literal, with the declaration on the same line + ['a line comment delimiter', "type Marker = '//'; interface ProcessEnv extends Something {}"], + // a block comment delimiter inside a literal, closed by a `*/` in a later literal + [ + 'a block comment delimiter', outdent` + type Open = '/*'; + interface ProcessEnv extends Something {} + type Close = '*/'; + `, + ], + ])('a declaration is still found past a string literal containing %s', async (_label, body) => { + await writeFile('other.d.ts', `declare namespace NodeJS {\n${body}\n}`); + const found = await findConflictingProcessEnvAugmentation({ + dirs: [tempDir], + outputPath: path.join(tempDir, 'env.d.ts'), + }); + expect(found).toBe(path.join(tempDir, 'other.d.ts')); + }); + + test('an apostrophe in a comment does not hide a declaration below it', async () => { + await writeFile('other.d.ts', outdent` + declare namespace NodeJS { + // don't let this comment's apostrophe open a literal + interface ProcessEnv extends Something {} + } + `); + const found = await findConflictingProcessEnvAugmentation({ + dirs: [tempDir], + outputPath: path.join(tempDir, 'env.d.ts'), + }); + expect(found).toBe(path.join(tempDir, 'other.d.ts')); + }); + + test('a `}` inside a string literal does not end the namespace early', async () => { + await writeFile('other.d.ts', outdent` + declare namespace NodeJS { + type Brace = '}'; + interface ProcessEnv extends Something {} + } + `); + const found = await findConflictingProcessEnvAugmentation({ + dirs: [tempDir], + outputPath: path.join(tempDir, 'env.d.ts'), + }); + expect(found).toBe(path.join(tempDir, 'other.d.ts')); + }); + + test('matches the nested `declare global` form we emit ourselves', async () => { + await writeFile('other.d.ts', outdent` + declare global { + namespace NodeJS { + interface ProcessEnv extends Something {} + } + } + `); + const found = await findConflictingProcessEnvAugmentation({ + dirs: [tempDir], + outputPath: path.join(tempDir, 'env.d.ts'), + }); + expect(found).toBe(path.join(tempDir, 'other.d.ts')); + }); + + test('defaults processEnv to none, and says why in the generated file', async () => { + await writeFile('worker-configuration.d.ts', FOREIGN_DTS); + const src = await generate('path=env.d.ts'); + expect(src).not.toContain('namespace NodeJS'); + expect(src).toContain('worker-configuration.d.ts'); + expect(src).toContain('set `processEnv=strict`'); + // only process.env defers; import.meta.env and the ENV types are untouched + expect(src).toContain('interface ImportMetaEnv'); + expect(src).toContain("declare module 'varlock/env'"); + }); + + test('explicit processEnv= wins over the deferral', async () => { + await writeFile('worker-configuration.d.ts', FOREIGN_DTS); + const src = await generate('path=env.d.ts, processEnv=strict'); + expect(src).toContain('namespace NodeJS'); + expect(src).not.toContain('was skipped because'); + }); + + test('emits the augmentation normally when nothing else declares ProcessEnv', async () => { + const src = await generate('path=env.d.ts'); + expect(src).toContain('namespace NodeJS'); + expect(src).not.toContain('was skipped because'); + }); + }); });