From c3f3ae51a61a967b161cee7a1a70f83d63ea49ee Mon Sep 17 00:00:00 2001 From: voiys Date: Sat, 24 Oct 2026 03:42:42 +0200 Subject: [PATCH 01/12] Add a shape mode to EnvGraph `new EnvGraph({ mode: 'shape' })` builds a graph for tools that only inspect a schema (type checkers, language servers, CI linters): - only resolvers marked `pure: true` run, all others resolve to undefined - an item whose value depends on a skipped resolver, directly or through references, is unknown: it is left unset and is not coerced, validated or reported as a missing or failed value - a @disable or @import condition that is unknown keeps the file enabled - root decorator `execute()` is skipped (the items they reference are still resolved, so they keep their final validation state) - no cache store is created - plugins that are not installed or cached are never downloaded - process.env overrides are ignored Skipped work is recorded in `graph.shapeSkipped`. Default mode is unchanged. The built-in resolvers that only read other values (concat, ref, fallback, forEnv, if, ...) and the builtin VARLOCK_* vars are marked pure; VARLOCK_BRANCH skips its local `git branch` lookup in shape mode. --- .../varlock/src/env-graph/lib/builtin-vars.ts | 4 + .../varlock/src/env-graph/lib/config-item.ts | 22 + .../varlock/src/env-graph/lib/data-source.ts | 11 +- .../varlock/src/env-graph/lib/env-graph.ts | 38 +- packages/varlock/src/env-graph/lib/plugins.ts | 10 +- .../varlock/src/env-graph/lib/resolver.ts | 50 +++ .../plugins/test-plugin-shape/package.json | 11 + .../test/plugins/test-plugin-shape/plugin.js | 22 + .../src/env-graph/test/shape-mode.test.ts | 412 ++++++++++++++++++ .../env-graph/test/type-generation/helpers.ts | 3 +- 10 files changed, 574 insertions(+), 9 deletions(-) create mode 100644 packages/varlock/src/env-graph/test/plugins/test-plugin-shape/package.json create mode 100644 packages/varlock/src/env-graph/test/plugins/test-plugin-shape/plugin.js create mode 100644 packages/varlock/src/env-graph/test/shape-mode.test.ts diff --git a/packages/varlock/src/env-graph/lib/builtin-vars.ts b/packages/varlock/src/env-graph/lib/builtin-vars.ts index 6e0f8bde2..6f4c0a84d 100644 --- a/packages/varlock/src/env-graph/lib/builtin-vars.ts +++ b/packages/varlock/src/env-graph/lib/builtin-vars.ts @@ -10,6 +10,8 @@ export type BuiltinVarDef = { resolver: ( ciEnv: CiEnvInfo, processEnv: Record, ) => string | boolean | undefined | Promise; + /** shape mode: true when producing the value would need a side effect (a process), so it is skipped */ + skippedInShapeLoad?: (ciEnv: CiEnvInfo, processEnv: Record) => boolean; /** * Warning to surface when the builtin's own resolver produced the value and something * in the schema actually reads it (e.g. VARLOCK_ENV being a low-confidence guess) @@ -118,6 +120,8 @@ export const BUILTIN_VARS: Record = { name: 'VARLOCK_BRANCH', description: 'Current git branch name. In CI, sourced from platform environment variables. Locally (non-CI), auto-detected via `git branch --show-current`.', resolver: (ciEnv) => ciEnv.branch ?? (!ciEnv.isCI ? getGitBranch() : undefined), + // the local lookup spawns git, which shape mode never does + skippedInShapeLoad: (ciEnv) => !ciEnv.branch && !ciEnv.isCI, }, VARLOCK_PR_NUMBER: { name: 'VARLOCK_PR_NUMBER', diff --git a/packages/varlock/src/env-graph/lib/config-item.ts b/packages/varlock/src/env-graph/lib/config-item.ts index a3718dba8..d01127fc8 100644 --- a/packages/varlock/src/env-graph/lib/config-item.ts +++ b/packages/varlock/src/env-graph/lib/config-item.ts @@ -860,6 +860,15 @@ export class ConfigItem { return undefined; } + /** + * @internal In shape mode: whether this item's value comes from a resolver that was skipped (or from an + * item whose value does), so an empty value means "unknown" rather than "unset". Only the value is + * considered, not decorators, so an empty item with a skipped `@sensitive` still fails a required check. + */ + get isValueUnknownInShapeLoad(): boolean { + return this.envGraph.mode === 'shape' && !!this.valueResolver?.resultUnknownInShapeLoad; + } + /** * Record the error for a required item with no value. A `@dynamic=boot` item is exempt when * the graph is resolving for a freeze (`deferBootRequired`): its value arrives at boot, where @@ -1097,6 +1106,7 @@ export class ConfigItem { this.isResolved = true; this.resolvedRawValue = undefined; } else { + if (this.envGraph.mode === 'shape') this.valueResolver.resetUnknownInShapeLoad(); try { this.resolvedRawValue = await this.valueResolver.resolve(); } catch (err) { @@ -1109,6 +1119,10 @@ export class ConfigItem { } } + // shape mode: a resolver that failed on a partial value (e.g. `domainFromUrl()` of a half-built url) + // did not really fail, the value is just unknown + if (this.resolutionError && this.isValueUnknownInShapeLoad) this.resolutionError = undefined; + if (this.resolvedRawValue instanceof RegExp) { this.resolutionError = new ResolutionError('Regex values are meant to be used within function args (e.g., remap()), not as a final resolved value'); } @@ -1118,6 +1132,14 @@ export class ConfigItem { this.isResolved = true; + // shape mode: a value built from a skipped resolver is unknown. Any value we computed is partial + // (e.g. `concat("https://", $UNKNOWN)`), so don't coerce or validate it and leave it unset + if (this.isValueUnknownInShapeLoad) { + this.resolvedRawValue = undefined; + this.resolvedValue = undefined; + this.isValidated = true; + return; + } // first deal with empty values and checking required if (this.resolvedRawValue === undefined || this.resolvedRawValue === '') { diff --git a/packages/varlock/src/env-graph/lib/data-source.ts b/packages/varlock/src/env-graph/lib/data-source.ts index f5905049a..f6e7d4ef7 100644 --- a/packages/varlock/src/env-graph/lib/data-source.ts +++ b/packages/varlock/src/env-graph/lib/data-source.ts @@ -351,7 +351,10 @@ export abstract class EnvGraphDataSource { } } - const disabledVal = await disabledDec.resolve(); + let disabledVal = await disabledDec.resolve(); + // shape mode: a condition built from a skipped resolver is unknown, so keep the source rather than + // dropping its items from the graph + if (this.graph.mode === 'shape' && disabledDec.decValueResolver?.resultUnknownInShapeLoad) disabledVal = false; if (!_.isBoolean(disabledVal)) { this._errors.push(new LoadingError('expected @disable to be boolean value')); return; @@ -585,7 +588,10 @@ export abstract class EnvGraphDataSource { } // Check if the import is enabled/disabled using key-val option (defaults to true if not specified) - const enabledValue = importArgs.obj.enabled ?? true; + const enabledResolver = importDec.decValueResolver?.objArgs?.enabled; + // shape mode: a condition built from a skipped resolver is unknown, so import the file anyway + const enabledUnknown = this.graph.mode === 'shape' && !!enabledResolver?.resultUnknownInShapeLoad; + const enabledValue = enabledUnknown ? true : importArgs.obj.enabled ?? true; if (!_.isBoolean(enabledValue)) { throw new Error('expected @import enabled parameter to be a boolean'); } @@ -595,7 +601,6 @@ export abstract class EnvGraphDataSource { // Track if this import was conditionally enabled (non-static enabled resolver) // Used by type generation to identify env-dependent sources - const enabledResolver = importDec.decValueResolver?.objArgs?.enabled; const isConditionallyEnabled = !!enabledResolver && !enabledResolver.isStatic; const importMeta = { isImport: true, importFilter, isConditionallyEnabled }; diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index c6a89a42d..a6657c7ad 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -230,6 +230,20 @@ export class EnvGraph { /** @internal --skip-cache flag: skip cache entirely */ _skipCacheMode = false; + /** + * `shape` builds the graph for tools that only inspect a schema (type checkers, language + * servers, CI linters): only pure resolvers run, root decorators are never executed, no cache + * store is created, plugins are never downloaded, and process.env is ignored. Anything skipped + * is recorded in `shapeSkipped`. + */ + readonly mode: 'default' | 'shape' = 'default'; + shapeSkipped: Array<{ + kind: 'resolver' | 'rootDecoratorExecute' | 'cacheStore' | 'pluginDownload', + name: string, + /** the item being resolved when a resolver was skipped, when known */ + itemKey?: string, + }> = []; + /** root data source (.env.schema) */ rootDataSource?: EnvGraphDataSource; @@ -486,7 +500,8 @@ export class EnvGraph { this.codeGeneratorsRegistry[name] = generatorDef; } - constructor() { + constructor(opts?: { mode?: 'default' | 'shape' }) { + if (opts?.mode) this.mode = opts.mode; // register base data types (string, number, boolean, etc) for (const dataType of BaseDataTypes) { this.registerDataType(dataType); @@ -513,7 +528,8 @@ export class EnvGraph { this.registerProxyTransformScheme(schemeName, schemeDef); } - this.overrideValues = originalProcessEnv; + // in shape mode the shell's env must not change what a schema means + this.overrideValues = this.mode === 'shape' ? {} : originalProcessEnv; } /** @@ -557,6 +573,8 @@ export class EnvGraph { const builtinType = builtinDef.type || 'string'; const BuiltinVarResolver = createResolver({ name: `\0builtin:${key}`, + // reads the environment only, apart from the opt-in skippedInShapeLoad lookups + pure: true, description: builtinDef.description, // Advertise the builtin's declared type so that if the item gets a // process() call (e.g. when registered early via a root-decorator @@ -569,6 +587,11 @@ export class EnvGraph { if (builtinItem) { builtinItem._builtinUsageWarning = builtinDef.usageWarning?.(graph.ciEnvInfo, graph.processEnvForBuiltins); } + if (graph.mode === 'shape' && builtinDef.skippedInShapeLoad?.(graph.ciEnvInfo, graph.processEnvForBuiltins)) { + graph.shapeSkipped.push({ kind: 'resolver', name: key }); + this._unknownInShapeLoad = true; + return undefined; + } return builtinDef.resolver(graph.ciEnvInfo, graph.processEnvForBuiltins); }, }); @@ -686,7 +709,10 @@ export class EnvGraph { `@cache resolved to an invalid value (${JSON.stringify(cacheSetting)}) — must be one of: "auto", "memory", "disk", "disabled"`, )); } - if (cacheMode === 'disabled') { + if (this.mode === 'shape') { + if (cacheMode !== 'disabled') this.shapeSkipped.push({ kind: 'cacheStore', name: cacheMode }); + this._cacheStore = undefined; + } else if (cacheMode === 'disabled') { this._cacheMode = 'disabled'; this._skipCacheMode = true; this._cacheStore = undefined; @@ -791,6 +817,12 @@ export class EnvGraph { // resolveEnvValues() skips any item whose deps are not in the set it was given const deps = this.expandKeysWithTransitiveDeps(decInstance.decValueResolver.deps); await this.resolveEnvValues([...deps]); + // shape mode still resolves the decorator's deps above (only pure resolvers run), which also + // re-validates them with their final metadata; it only skips the side-effecting execute() + if (this.mode === 'shape') { + this.shapeSkipped.push({ kind: 'rootDecoratorExecute', name: decInstance.name }); + continue; + } try { await decInstance.execute(); } catch (err) { diff --git a/packages/varlock/src/env-graph/lib/plugins.ts b/packages/varlock/src/env-graph/lib/plugins.ts index fc471dbfe..358c074bc 100644 --- a/packages/varlock/src/env-graph/lib/plugins.ts +++ b/packages/varlock/src/env-graph/lib/plugins.ts @@ -907,8 +907,14 @@ export async function processPluginInstallDecorators(dataSource: EnvGraphDataSou // published npm versions are immutable, so check the cache before asking the registry. // This keeps loads fast and lets pre-cached plugins work offline. const expectedTarballUrl = getNpmTarballUrl(moduleName, versionDescriptor); - pluginSrcPath = await getCachedPluginDir(expectedTarballUrl) - ?? await installPluginFromNpm(moduleName, versionDescriptor, expectedTarballUrl); + pluginSrcPath = await getCachedPluginDir(expectedTarballUrl); + if (!pluginSrcPath && graph.mode === 'shape') { + graph.shapeSkipped.push({ kind: 'pluginDownload', name: `${moduleName}@${versionDescriptor}` }); + throw new SchemaError(`Plugin "${moduleName}@${versionDescriptor}" is not installed or cached, and shape mode never downloads plugins`, { + location: getErrorLocation(dataSource, pluginDecorator), + }); + } + pluginSrcPath ??= await installPluginFromNpm(moduleName, versionDescriptor, expectedTarballUrl); } } diff --git a/packages/varlock/src/env-graph/lib/resolver.ts b/packages/varlock/src/env-graph/lib/resolver.ts index d01a4504c..773ea444d 100644 --- a/packages/varlock/src/env-graph/lib/resolver.ts +++ b/packages/varlock/src/env-graph/lib/resolver.ts @@ -155,6 +155,21 @@ export class Resolver { get deps() { return Object.keys(this.depsObj); } + /** @internal shape mode: this resolver was skipped, or read an item whose value was */ + _unknownInShapeLoad = false; + /** + * @internal shape mode: whether the value this resolver produced (the last time it ran) depends on a + * resolver that was skipped, so an empty result means "unknown" rather than "unset". Only resolvers that + * actually ran count, so an untaken `if()` branch does not. + */ + get resultUnknownInShapeLoad(): boolean { + return this._unknownInShapeLoad || this.childResolvers.some((r) => r.resultUnknownInShapeLoad); + } + /** @internal shape mode: forget what an earlier run skipped, before resolving again */ + resetUnknownInShapeLoad() { + this._unknownInShapeLoad = false; + this.childResolvers.forEach((r) => r.resetUnknownInShapeLoad()); + } private parent?: ConfigItem | DecoratorInstance; @@ -248,6 +263,14 @@ export class Resolver { } async resolve() { + const graph = this.envGraph ?? this.dataSource?.graph; + if (graph?.mode === 'shape' && !this.def.pure) { + const { getResolutionContext } = await import('./resolution-context'); + const item = getResolutionContext()?.currentItem; + graph.shapeSkipped.push({ kind: 'resolver', name: this.def.name, ...item && { itemKey: item.key } }); + this._unknownInShapeLoad = true; + return undefined; + } try { const resolvedValue = await this.def.resolve.call(this, this.meta); return resolvedValue; @@ -299,6 +322,7 @@ export class Resolver { // would silently produce undefined instead of the item's actual value if (!depItem.isResolved) throw new Error(`Referenced item "${key}" has not been resolved yet`); if (depItem.isBuiltin) depItem._builtinValueUsed = true; + if (depItem.isValueUnknownInShapeLoad) this._unknownInShapeLoad = true; return depItem.resolvedValue; } } @@ -315,6 +339,12 @@ export type ResolverDef = { inferredTypeSettings?: Record; /** If true, using this resolver implies the item is sensitive (unless explicitly overridden) */ impliesSensitive?: boolean; + /** + * True when resolving only reads other values and has no side effects (no shell, no network, + * no filesystem, no secret stores, no randomness). An EnvGraph in `shape` mode only runs + * pure resolvers; every other resolver resolves to undefined there. + */ + pure?: boolean; argsSchema?: { type: 'array' | 'object' | 'mixed'; arrayExactLength?: number; @@ -331,6 +361,7 @@ export type ResolverDef = { export class StaticValueResolver extends Resolver { static def = { name: '\0static', // used internally, so we add the extra \0 + pure: true, icon: 'bi:dash', async resolve(this: Resolver) { return (this as StaticValueResolver).staticValue; @@ -356,6 +387,7 @@ export class FunctionArgsResolver extends Resolver { // so that some can be skipped depending on the other args static def = { name: '\0fnArgs', // used internally, so we add the extra \0 + pure: true, label: 'function args', icon: 'bi:dash', // not actualyl used @@ -382,6 +414,7 @@ export class FunctionArgsResolver extends Resolver { export class ObjectLiteralResolver extends Resolver { static def = { name: '\0objectLiteral', // used internally, so we add the extra \0 + pure: true, label: 'object literal', icon: 'bi:braces', resolve() { return undefined; }, @@ -403,6 +436,7 @@ export class ObjectLiteralResolver extends Resolver { export class ArrayLiteralResolver extends Resolver { static def = { name: '\0arrayLiteral', // used internally, so we add the extra \0 + pure: true, label: 'array literal', icon: 'bi:bracket', resolve() { return undefined; }, @@ -423,6 +457,7 @@ export class ArrayLiteralResolver extends Resolver { export class ErrorResolver extends Resolver { static def: ResolverDef = { name: '\0error', // used internally, so we add the extra \0 + pure: true, icon: 'bi:dash', async resolve() { return undefined; }, }; @@ -445,6 +480,7 @@ export function createResolver( export const ConcatResolver: typeof Resolver = createResolver({ name: 'concat', + pure: true, icon: 'material-symbols:join', inferredType: 'string', argsSchema: { @@ -465,6 +501,7 @@ export const ConcatResolver: typeof Resolver = createResolver({ export const FallbackResolver: typeof Resolver = createResolver({ name: 'fallback', + pure: true, icon: 'memory:table-top-stairs-up', argsSchema: { type: 'array', @@ -519,6 +556,7 @@ export const ExecResolver: typeof Resolver = createResolver({ export const RefResolver: typeof Resolver = createResolver({ name: 'ref', + pure: true, icon: 'mdi-light:content-duplicate', argsSchema: { type: 'array', @@ -553,6 +591,7 @@ export const RefResolver: typeof Resolver = createResolver({ // we will check final resoled values to make sure they are not regexes export const RegexResolver: typeof Resolver = createResolver({ name: 'regex', + pure: true, icon: 'mdi:regex', argsSchema: { type: 'array', @@ -590,6 +629,7 @@ export const RegexResolver: typeof Resolver = createResolver({ export const RemapResolver: typeof Resolver = createResolver({ name: 'remap', + pure: true, icon: 'codicon:replace', argsSchema: { // supports both old key=value syntax and new positional syntax @@ -685,6 +725,7 @@ export const RemapResolver: typeof Resolver = createResolver({ export const ForEnvResolver: typeof Resolver = createResolver({ name: 'forEnv', + pure: true, icon: 'tabler:flag-question', inferredType: 'boolean', argsSchema: { @@ -714,6 +755,7 @@ export const ForEnvResolver: typeof Resolver = createResolver({ export const EqResolver: typeof Resolver = createResolver({ name: 'eq', + pure: true, icon: 'material-symbols:equal', inferredType: 'boolean', argsSchema: { @@ -732,6 +774,7 @@ export const EqResolver: typeof Resolver = createResolver({ export const IfResolver: typeof Resolver = createResolver({ name: 'if', + pure: true, icon: 'material-symbols:help-center', // question mark argsSchema: { type: 'array', @@ -769,6 +812,7 @@ export const IfResolver: typeof Resolver = createResolver({ export const IfsResolver: typeof Resolver = createResolver({ name: 'ifs', + pure: true, icon: 'material-symbols:rule', argsSchema: { type: 'array', @@ -794,6 +838,7 @@ export const IfsResolver: typeof Resolver = createResolver({ export const NotResolver: typeof Resolver = createResolver({ name: 'not', + pure: true, icon: 'material-symbols:not-equal', inferredType: 'boolean', argsSchema: { @@ -808,6 +853,7 @@ export const NotResolver: typeof Resolver = createResolver({ export const AndResolver: typeof Resolver = createResolver({ name: 'and', + pure: true, icon: 'material-symbols:join-inner', inferredType: 'boolean', argsSchema: { @@ -825,6 +871,7 @@ export const AndResolver: typeof Resolver = createResolver({ export const OrResolver: typeof Resolver = createResolver({ name: 'or', + pure: true, icon: 'material-symbols:join-full', inferredType: 'boolean', argsSchema: { @@ -842,6 +889,7 @@ export const OrResolver: typeof Resolver = createResolver({ export const IsEmptyResolver: typeof Resolver = createResolver({ name: 'isEmpty', + pure: true, icon: 'material-symbols:empty', inferredType: 'boolean', argsSchema: { @@ -892,6 +940,7 @@ function extractDomainFromUrl(url: string): string | undefined { export const DomainFromUrlResolver: typeof Resolver = createResolver({ name: 'domainFromUrl', + pure: true, icon: 'mdi:web', inferredType: 'domain', // the input is any url, so the result can legitimately be a single-label host (`localhost`) @@ -1333,6 +1382,7 @@ export const CacheResolver: typeof Resolver = createResolver({ // but resolved within the context of a specific item export const InferFromPrefixResolver: typeof Resolver = createResolver({ name: 'inferFromPrefix', + pure: true, icon: 'material-symbols:help-center', // question mark argsSchema: { type: 'array', diff --git a/packages/varlock/src/env-graph/test/plugins/test-plugin-shape/package.json b/packages/varlock/src/env-graph/test/plugins/test-plugin-shape/package.json new file mode 100644 index 000000000..c58f5576c --- /dev/null +++ b/packages/varlock/src/env-graph/test/plugins/test-plugin-shape/package.json @@ -0,0 +1,11 @@ +{ + "name": "@varlock/test-plugin-shape", + "private": true, + "description": "plugin used in automated tests of shape mode", + "version": "1.0.0", + "type": "module", + "exports": { + "./plugin": "./plugin.js" + }, + "files": ["dist"] +} diff --git a/packages/varlock/src/env-graph/test/plugins/test-plugin-shape/plugin.js b/packages/varlock/src/env-graph/test/plugins/test-plugin-shape/plugin.js new file mode 100644 index 000000000..25e32826a --- /dev/null +++ b/packages/varlock/src/env-graph/test/plugins/test-plugin-shape/plugin.js @@ -0,0 +1,22 @@ +const fs = require('node:fs'); +const { plugin } = require('varlock/plugin-lib'); + +// no `pure` flag: has a side effect, so shape mode must not run it +plugin.registerResolverFunction({ + name: 'shapeTouch', + argsSchema: { type: 'array', arrayExactLength: 1 }, + async resolve() { + fs.writeFileSync(await this.arrArgs[0].resolve(), 'ran'); + return 'touched'; + }, +}); + +// only reads its args: marked pure, so shape mode runs it +plugin.registerResolverFunction({ + name: 'shapeShout', + pure: true, + argsSchema: { type: 'array', arrayExactLength: 1 }, + async resolve() { + return String(await this.arrArgs[0].resolve()).toUpperCase(); + }, +}); diff --git a/packages/varlock/src/env-graph/test/shape-mode.test.ts b/packages/varlock/src/env-graph/test/shape-mode.test.ts new file mode 100644 index 000000000..af2659773 --- /dev/null +++ b/packages/varlock/src/env-graph/test/shape-mode.test.ts @@ -0,0 +1,412 @@ +import { + describe, test, expect, vi, afterEach, +} from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import outdent from 'outdent'; +import { + EnvGraph, DotEnvFileDataSource, collectTypeGenItems, generateTsTypesSrc, resolveFieldTypes, +} from '../index'; +import { loadGraph } from './type-generation/helpers'; + +// Each side-effect test has a positive control: the same schema in default mode must perform the +// side effect, so a passing shape-mode test can't come from a probe that never fires. + +const tmpMarker = () => path.join(os.tmpdir(), `varlock-shape-${process.pid}-${Math.random().toString(36).slice(2)}`); + +// A command that creates `marker` and prints `out`. Uses node rather than touch/echo so it also runs +// on Windows, and forward slashes so the path survives both shells. +const touchCmd = (marker: string, out: string) => { + const m = marker.replace(/\\/g, '/'); + return `node -e "require('fs').writeFileSync('${m}', ''); process.stdout.write('${out}')"`; +}; + +async function load( + envFile: string, + mode: 'default' | 'shape', + opts?: { resolve?: boolean, processEnv?: Record }, +) { + const g = new EnvGraph({ mode }); + // guards the parity tests below: on a build without shape mode both graphs would be default mode + expect(g.mode).toBe(mode); + if (opts?.processEnv) g.processEnvOverride = opts.processEnv; + const source = new DotEnvFileDataSource(path.join(__dirname, '.env.schema'), { overrideContents: envFile }); + await g.setRootDataSource(source); + await g.finishLoad(); + if (opts?.resolve) await g.resolveEnvValues(); + return { g, source }; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +describe('EnvGraph shape mode', () => { + test('exec() in an item value never runs, even when values are resolved', async () => { + const marker = tmpMarker(); + const schema = outdent` + # --- + TOKEN=exec(\`${touchCmd(marker, 'secret')}\`) + `; + await load(schema, 'shape', { resolve: true }); + expect(fs.existsSync(marker)).toBe(false); + + const { g } = await load(schema, 'default', { resolve: true }); + expect(fs.existsSync(marker)).toBe(true); // control + expect(g.configSchema.TOKEN.resolvedValue).toBe('secret'); + fs.rmSync(marker, { force: true }); + }); + + test('a shell command in the @cache value never runs', async () => { + const marker = tmpMarker(); + const schema = outdent` + # @cache=exec(\`${touchCmd(marker, 'memory')}\`) + # --- + A=1 + `; + await load(schema, 'shape'); + expect(fs.existsSync(marker)).toBe(false); + + await load(schema, 'default'); + expect(fs.existsSync(marker)).toBe(true); // control + fs.rmSync(marker, { force: true }); + }); + + test('@cache never creates a cache store', async () => { + const schema = outdent` + # @cache=memory + # --- + A=1 + `; + const { g } = await load(schema, 'shape'); + expect(g._cacheStore).toBeUndefined(); + expect(g.shapeSkipped).toContainEqual({ kind: 'cacheStore', name: 'memory' }); + + const { g: d } = await load(schema, 'default'); + expect(d._cacheStore).toBeDefined(); // control + }); + + test('root decorators are not executed (@setValuesBulk with exec)', async () => { + const marker = tmpMarker(); + const schema = outdent` + # @setValuesBulk(exec(\`${touchCmd(marker, '{}')}\`), format=json) + # --- + A=1 + `; + const { g } = await load(schema, 'shape'); + expect(fs.existsSync(marker)).toBe(false); + expect(g.shapeSkipped).toContainEqual({ kind: 'rootDecoratorExecute', name: 'setValuesBulk' }); + + await load(schema, 'default'); + expect(fs.existsSync(marker)).toBe(true); // control + fs.rmSync(marker, { force: true }); + }); + + test('a pinned plugin that is not installed or cached is never downloaded', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network disabled in test')); + const schema = outdent` + # @plugin(@varlock/shape-mode-test-plugin@9.9.9) + # --- + A=1 + `; + const { source, g } = await load(schema, 'shape'); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(g.shapeSkipped).toContainEqual({ kind: 'pluginDownload', name: '@varlock/shape-mode-test-plugin@9.9.9' }); + expect(source.errors.map((e) => e.message).join('\n')).toMatch(/shape mode never downloads plugins/); + + await load(schema, 'default'); + expect(fetchSpy).toHaveBeenCalled(); // control: default mode asks the registry + }); + + test('pure resolvers still run: @currentEnv, forEnv, concat, ref, fallback', async () => { + const { g } = await load(outdent` + # @currentEnv=$APP_ENV + # --- + # @type=enum(development, production) + APP_ENV=production + HOST=example.com + URL=concat("https://", $HOST) + LABEL=fallback($MISSING_OPTIONAL, "default") + # @optional + MISSING_OPTIONAL= + # @required=forEnv(production) + PROD_ONLY= + `, 'shape', { resolve: true }); + expect(g.configSchema.URL.resolvedValue).toBe('https://example.com'); + expect(g.configSchema.LABEL.resolvedValue).toBe('default'); + expect(g.configSchema.PROD_ONLY.isRequired).toBe(true); + expect(g.shapeSkipped.filter((s) => s.kind === 'resolver')).toEqual([]); + }); + + test('a required item whose resolver was skipped is unknown, not a missing value', async () => { + const schema = outdent` + # @defaultRequired=true + # --- + SECRET=exec("echo hi") + COPY=$SECRET + PLAIN= + # a resolver in the branch that is not taken is never skipped, so this is really empty + UNTAKEN_BRANCH=if(eq(a, b), exec("echo hi"), "") + # a skipped resolver in a decorator does not make the item's own value unknown + # @sensitive=eq(exec("echo yes"), yes) + EMPTY_WITH_SKIPPED_DECORATOR= + `; + const { g } = await load(schema, 'shape', { resolve: true }); + expect(g.configSchema.SECRET.errors).toEqual([]); + expect(g.configSchema.COPY.errors).toEqual([]); // follows references + expect(g.shapeSkipped).toContainEqual({ kind: 'resolver', name: 'exec', itemKey: 'SECRET' }); + // control: a required item that is really empty still fails + for (const key of ['PLAIN', 'UNTAKEN_BRANCH', 'EMPTY_WITH_SKIPPED_DECORATOR']) { + expect(g.configSchema[key].errors.map((e) => e.message)).toEqual(['Value is required but is currently empty']); + } + + const { g: d } = await load(schema, 'default', { resolve: true }); + expect(d.configSchema.SECRET.resolvedValue).toBe('hi'); + }); + + test('a value derived from a skipped resolver is not coerced or validated, a real error still is', async () => { + const schema = outdent` + # --- + HOST=exec("echo example.com") + # @type=url + API=concat("https://", $HOST) + # @type=url + BAD_STATIC=not a url + `; + const { g } = await load(schema, 'shape', { resolve: true }); + expect(g.configSchema.API.errors).toEqual([]); + expect(g.configSchema.API.resolvedValue).toBeUndefined(); + expect(g.configSchema.BAD_STATIC.errors.length).toBeGreaterThan(0); + // a resolver that throws on the partial value (here domainFromUrl) is not a real failure either, nor is what depends on it + const nested = await load(outdent` + HOST=exec("echo example.com") + DOMAIN=domainFromUrl(concat("https://", $HOST)) + LABEL=concat("domain: ", $DOMAIN) + `, 'shape', { resolve: true }); + for (const key of ['HOST', 'DOMAIN', 'LABEL']) expect(nested.g.configSchema[key].errors).toEqual([]); + + const { g: d } = await load(schema, 'default', { resolve: true }); + expect(d.configSchema.API.resolvedValue).toBe('https://example.com'); + expect(d.configSchema.BAD_STATIC.errors.length).toBeGreaterThan(0); + }); + + test('resolving again forgets what an earlier run skipped', async () => { + const { g } = await load(outdent` + # @type=boolean + FLAG=false + VALUE=if($FLAG, "known", exec("echo hidden")) + `, 'shape', { resolve: true }); + expect(g.configSchema.VALUE.isValueUnknownInShapeLoad).toBe(true); + + // flip the flag so the untouched, static branch is taken on the next resolve + g.overrideValues.FLAG = 'true'; + await g.configSchema.FLAG.resolve(true); + await g.configSchema.VALUE.resolve(true); + expect(g.configSchema.VALUE.resolvedValue).toBe('known'); + expect(g.configSchema.VALUE.isValueUnknownInShapeLoad).toBe(false); + }); + + test('a @disable or @import condition built from a skipped resolver keeps the file', async () => { + const files = { + '.env.schema': outdent` + # @import(./.env.imported, enabled=not(isEmpty(exec("echo yes")))) + # @import(./.env.second) + # --- + ROOT=1 + `, + '.env.imported': 'IMPORTED=1', + '.env.second': outdent` + # @disable=isEmpty(exec("echo yes")) + # --- + SECOND=1 + `, + }; + const keys = async (mode: 'default' | 'shape') => { + const g = await loadGraph({ files, mode }); + expect(g.mode).toBe(mode); + return Object.keys(g.configSchema).sort(); + }; + expect(await keys('default')).toEqual(['IMPORTED', 'ROOT', 'SECOND']); // control + expect(await keys('shape')).toEqual(['IMPORTED', 'ROOT', 'SECOND']); + }); + + test('an item read early by a root decorator and backed by a skipped resolver is unknown too', async () => { + const schema = outdent` + # @cache=$CACHE_MODE + # @defaultRequired=true + # --- + CACHE_MODE=exec("echo memory") + `; + const { g, source } = await load(schema, 'shape', { resolve: true }); + expect(g.configSchema.CACHE_MODE.errors).toEqual([]); + expect(source.errors.filter((e) => !e.isWarning)).toEqual([]); + expect(g.shapeSkipped).toContainEqual(expect.objectContaining({ kind: 'resolver', name: 'exec' })); + }); + + test('plugin resolvers are skipped unless they set pure: true', async () => { + const marker = tmpMarker(); + const schema = outdent` + # @plugin(./plugins/test-plugin-shape/) + # --- + TOUCHED=shapeTouch("${marker}") + SHOUTED=shapeShout("hi") + `; + const { g } = await load(schema, 'shape', { resolve: true }); + expect(fs.existsSync(marker)).toBe(false); + expect(g.configSchema.TOUCHED.resolvedValue).toBeUndefined(); + expect(g.configSchema.SHOUTED.resolvedValue).toBe('HI'); + expect(g.shapeSkipped).toContainEqual({ kind: 'resolver', name: 'shapeTouch', itemKey: 'TOUCHED' }); + expect(g.shapeSkipped.map((s) => s.name)).not.toContain('shapeShout'); + + const { g: d } = await load(schema, 'default', { resolve: true }); + expect(fs.existsSync(marker)).toBe(true); // control + expect(d.configSchema.TOUCHED.resolvedValue).toBe('touched'); + fs.rmSync(marker, { force: true }); + }); + + test('builtin vars still resolve from the environment: @currentEnv=$VARLOCK_ENV', async () => { + const schema = outdent` + # @currentEnv=$VARLOCK_ENV + # --- + # @required=forEnv(test) + TEST_ONLY= + `; + const { g } = await load(schema, 'shape', { resolve: true, processEnv: { NODE_ENV: 'test' } }); + expect(g.configSchema.VARLOCK_ENV.resolvedValue).toBe('test'); + expect(g.configSchema.TEST_ONLY.isRequired).toBe(true); + expect(g.shapeSkipped.filter((s) => s.kind === 'resolver')).toEqual([]); + }); + + // the fake git is a shell script + test.skipIf(process.platform === 'win32')('VARLOCK_BRANCH does not spawn git', async () => { + // a fake `git` first on PATH records that it was run + const marker = tmpMarker(); + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-shape-bin-')); + fs.writeFileSync(path.join(binDir, 'git'), `#!/bin/sh\ntouch "${marker}"\necho fake-branch\n`, { mode: 0o755 }); + vi.stubEnv('PATH', `${binDir}${path.delimiter}${process.env.PATH}`); + const schema = 'BRANCH=$VARLOCK_BRANCH'; + + const { g } = await load(`# @defaultRequired=true\n# ---\n${schema}`, 'shape', { resolve: true, processEnv: {} }); + expect(fs.existsSync(marker)).toBe(false); + expect(g.configSchema.VARLOCK_BRANCH.resolvedValue).toBeUndefined(); + // the skipped lookup is recorded, and the unknown branch is not reported as a missing required value + expect(g.shapeSkipped).toContainEqual(expect.objectContaining({ kind: 'resolver', name: 'VARLOCK_BRANCH' })); + expect(g.configSchema.BRANCH.errors).toEqual([]); + + const { g: d } = await load(schema, 'default', { resolve: true, processEnv: {} }); + expect(fs.existsSync(marker)).toBe(true); // control + expect(d.configSchema.VARLOCK_BRANCH.resolvedValue).toBe('fake-branch'); + + // a branch reported by the CI platform is only read from the environment, so it still works + const ci = await load(schema, 'shape', { resolve: true, processEnv: { GITHUB_ACTIONS: 'true', GITHUB_REF_NAME: 'my-branch' } }); + expect(ci.g.configSchema.VARLOCK_BRANCH.resolvedValue).toBe('my-branch'); + fs.rmSync(marker, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + }); + + test('a process.env value does not override the schema', async () => { + // the graph snapshots process.env when varlock is first imported, so import it again after setting the var + vi.stubEnv('SHAPE_MODE_SHELL_VALUE', 'from-shell'); + vi.resetModules(); + const fresh = await import('../index'); + const resolveA = async (mode: 'default' | 'shape') => { + const g = new fresh.EnvGraph({ mode }); + const source = new fresh.DotEnvFileDataSource(path.join(__dirname, '.env.schema'), { + overrideContents: '# ---\nSHAPE_MODE_SHELL_VALUE=from-schema', + }); + await g.setRootDataSource(source); + await g.finishLoad(); + await g.resolveEnvValues(); + return g.configSchema.SHAPE_MODE_SHELL_VALUE.resolvedValue; + }; + expect(await resolveA('shape')).toBe('from-schema'); + expect(await resolveA('default')).toBe('from-shell'); // control + vi.resetModules(); + }); + + test('validation warnings match default mode (root decorator deps are re-validated with final metadata)', async () => { + // regression: skipping the whole root-decorator loop left @currentEnv's item with a stale + // "very short sensitive value" warning from its early resolve, before @defaultSensitive applied + // forEnv() resolves APP_ENV early, before its sensitivity is known; only the root-decorator loop + // (resolving @currentEnv's deps) re-validates it afterwards + const schema = outdent` + # @currentEnv=$APP_ENV + # @defaultRequired=true @defaultSensitive=false + # --- + APP_ENV=development + # @required=forEnv(production) + OP_TOKEN= + `; + const warnings = async (mode: 'default' | 'shape') => { + const { g } = await load(schema, mode, { resolve: true }); + return g.sortedConfigKeys.flatMap((k) => g.configSchema[k].errors.map((e) => `${k}: ${e.message}`)); + }; + expect(await warnings('shape')).toEqual(await warnings('default')); + }); + + test('generated TypeScript is identical to default mode, across an import and an env-specific file', async () => { + const files = { + '.env.schema': outdent` + # @currentEnv=$APP_ENV @defaultSensitive=false + # @import(./.env.imported) + # --- + # @type=enum(development, production) + APP_ENV=development + TOKEN=exec("echo hi") + # @required=forEnv(production) + PROD_ONLY= + `, + '.env.imported': outdent` + # @type=port + IMPORTED_PORT=3000 + `, + '.env.production': outdent` + PROD_ONLY=from-production-file + TOKEN=overridden + `, + }; + const generate = async (mode: 'default' | 'shape') => { + const g = await loadGraph({ files, overrideValues: { APP_ENV: 'production' }, mode }); + expect(g.mode).toBe(mode); + return generateTsTypesSrc(resolveFieldTypes(await collectTypeGenItems(g))); + }; + const shapeSrc = await generate('shape'); + expect(shapeSrc).toContain('IMPORTED_PORT'); + expect(shapeSrc).not.toContain('from-production-file'); + expect(shapeSrc).toBe(await generate('default')); + }); + + test('generated types are the same as in default mode', async () => { + const schema = outdent` + # @currentEnv=$APP_ENV + # @defaultRequired=infer + # --- + # @type=enum(development, staging, production) + APP_ENV=development + # @type=port + PORT=3000 + # @type=url + API=concat("https://", $HOST, "/api") + HOST=example.com + # @required=forEnv(production) + SENTRY_DSN= + TOKEN=exec("echo hi") + # @sensitive + SECRET= + `; + const info = async (mode: 'default' | 'shape') => { + const { g } = await load(schema, mode); + const out: Record = {}; + for (const key of g.sortedConfigKeys) { + const i = await g.configSchema[key].getTypeGenInfo(); + out[key] = { + type: i.dataType?.coercedType ?? 'string', required: i.isRequired, requiredDynamic: i.isRequiredDynamic, sensitive: i.isSensitive, + }; + } + return out; + }; + expect(await info('shape')).toEqual(await info('default')); + }); +}); diff --git a/packages/varlock/src/env-graph/test/type-generation/helpers.ts b/packages/varlock/src/env-graph/test/type-generation/helpers.ts index cf4059c07..2052430ed 100644 --- a/packages/varlock/src/env-graph/test/type-generation/helpers.ts +++ b/packages/varlock/src/env-graph/test/type-generation/helpers.ts @@ -32,11 +32,12 @@ export async function loadGraph(spec: { fallbackEnv?: string; /** extra data types to register, as a plugin's registerDataType would */ dataTypes?: Array; + mode?: 'default' | 'shape'; }) { const currentDir = path.dirname(expect.getState().testPath!); vi.spyOn(process, 'cwd').mockReturnValue(currentDir); - const g = new EnvGraph(); + const g = new EnvGraph({ mode: spec.mode }); for (const dataType of spec.dataTypes ?? []) g.registerDataType(dataType); if (spec.overrideValues) g.overrideValues = spec.overrideValues; if (spec.fallbackEnv) g.envFlagFallback = spec.fallbackEnv; From 831a6b15c06541c48a82adbf4a7edea10ca72091 Mon Sep 17 00:00:00 2001 From: voiys Date: Sat, 24 Oct 2026 03:42:43 +0200 Subject: [PATCH 02/12] docs: add a shape mode guide and a bump file Documents shape mode for tool authors, including the `pure` flag for plugin resolvers. --- .bumpy/env-graph-shape-mode.md | 5 + .../src/content/docs/guides/shape-mode.mdx | 91 +++++++++++++++++++ packages/varlock-website/src/sidebar.ts | 1 + 3 files changed, 97 insertions(+) create mode 100644 .bumpy/env-graph-shape-mode.md create mode 100644 packages/varlock-website/src/content/docs/guides/shape-mode.mdx diff --git a/.bumpy/env-graph-shape-mode.md b/.bumpy/env-graph-shape-mode.md new file mode 100644 index 000000000..5877e0752 --- /dev/null +++ b/.bumpy/env-graph-shape-mode.md @@ -0,0 +1,5 @@ +--- +varlock: minor +--- + +Add a shape mode for tools that only inspect a schema. `new EnvGraph({ mode: "shape" })` runs only pure resolvers, skips root decorator execution and the cache, never downloads plugins, and ignores `process.env` overrides. Plugin resolvers can opt in with `pure: true`. diff --git a/packages/varlock-website/src/content/docs/guides/shape-mode.mdx b/packages/varlock-website/src/content/docs/guides/shape-mode.mdx new file mode 100644 index 000000000..714bae830 --- /dev/null +++ b/packages/varlock-website/src/content/docs/guides/shape-mode.mdx @@ -0,0 +1,91 @@ +--- +title: Shape mode +description: Load a schema for editors, linters, and type checkers while skipping exec(), provider lookups, and plugin downloads +--- + +Loading a schema normally does real work. `exec()` runs a shell command, plugin resolvers call secret stores, `@cache` opens an encrypted cache, root decorators like `@setValuesBulk` and plugin `@init*` decorators run, and a pinned plugin that isn't installed is downloaded from npm. That is what you want when running an app, but not when a tool only needs to read the schema: a type checker, a language server, or a CI lint. + +Shape mode builds the graph (items, types, decorators, requiredness, sensitivity, errors) without running any of that. It is a programmatic API for tool authors. To check your own env, use [`varlock load`](/reference/cli/load-and-run/#load). + +:::caution[Installed plugins still run] +Shape mode skips `exec()`, other resolvers with side effects, and root decorator execution, and it never downloads a plugin. A plugin that is already installed or cached is still imported, and its module code and `process()` hooks run. See [Limits](#limits). +::: + +## Using it + +Pass `mode: 'shape'` when creating the graph. The graph classes are exported from `varlock` as `internal`. + +```js title="check-schema.mjs" +import { internal } from 'varlock'; + +const { EnvGraph, DotEnvFileDataSource } = internal; + +const graph = new EnvGraph({ mode: 'shape' }); +await graph.setRootDataSource(new DotEnvFileDataSource('./.env.schema')); +await graph.finishLoad(); + +// a source that fails to parse or a missing plugin stops loading early, and items can have schema errors +const problems = [ + ...graph.sortedDataSources.flatMap((source) => source.errors), + ...Object.values(graph.configSchema).flatMap((item) => item.errors), +].filter((e) => !e.isWarning); +if (problems.length) { + for (const err of problems) console.error(err.message); + process.exit(1); +} + +for (const key of graph.sortedConfigKeys) { + const info = await graph.configSchema[key].getTypeGenInfo(); + console.log(key, info.dataType?.coercedType ?? 'string', info.isRequired); +} + +// everything that was not run, e.g. [{ kind: 'rootDecoratorExecute', name: 'currentEnv' }] +console.log(graph.shapeSkipped); +``` + +This loads the schema file and the files it imports. Value files such as `.env.local` are not picked up by a `DotEnvFileDataSource` pointed at the schema. Calling `graph.resolveEnvValues()` also works in this mode. It only runs the [pure resolvers](#pure-resolvers) listed below. + +## What changes + +| | Default mode | Shape mode | +|---|---|---| +| Resolver functions | all run | only [pure](#pure-resolvers) ones run, the rest resolve to `undefined` | +| Root decorators | `execute()` runs | `execute()` is skipped, so values set by `@setValuesBulk` and items it creates with `createMissing=true` are not in the graph. Items referenced in their arguments (`$VAR`) are still resolved and validated | +| Cache | `@cache` opens a store | no cache store is created | +| Plugins | a pinned plugin is downloaded if missing | never downloaded. If it isn't installed or cached, loading reports a schema error | +| `process.env` | overrides item values | overrides are ignored. Values come from the files that were loaded, and builtin variables still read the environment | + +Everything skipped is recorded in `graph.shapeSkipped` as `{ kind, name, itemKey? }`, where `kind` is `resolver`, `rootDecoratorExecute`, `cacheStore`, or `pluginDownload`, and `itemKey` is the item whose value was being resolved when a resolver was skipped. Tools can use it to tell users which values could not be computed. + +Apart from those differences, a tool sees the same types that [code generation](/guides/code-generation/) produces, and the same requiredness, sensitivity, and validation messages, except where a decorator or function depends on a value that was skipped (see [Pure resolvers](#pure-resolvers)). [Builtin variables](/reference/builtin-variables/) such as `VARLOCK_ENV` are still read from the environment, except that outside CI `VARLOCK_BRANCH` is unknown (and listed in `shapeSkipped`), because the local `git branch --show-current` lookup is skipped. In CI it is read from the platform's variables as usual. + +### Pure resolvers + +These built-in resolvers only read other values, so they still run: `concat()`, `fallback()`, `ref()`, `regex()`, `remap()`, `forEnv()`, `eq()`, `if()`, `ifs()`, `not()`, `and()`, `or()`, `isEmpty()`, `domainFromUrl()`, and `inferFromPrefix()`. That is enough to evaluate `@currentEnv`, `@required=forEnv(production)`, and `${REF}` expansion. + +Everything else resolves to `undefined`: [`exec()`](/reference/functions/#exec), the random generators, [`generateOtp()`](/reference/functions/#generateotp), [`cache()`](/reference/functions/#cache), and plugin resolvers that are not [marked pure](#marking-plugin-resolvers-as-pure). An item whose value depends on one of them (directly, or through a reference like `COPY=$TOKEN`) is unknown. It is left unset and is not coerced, validated, or reported as a missing required value. A `@disable` or `@import(..., enabled=...)` condition that depends on one is treated as enabled, so the file's items stay in the graph. An error that a resolver raises on a partial value is cleared for an item's value, so an unrelated real error inside the same expression is hidden too. It is not cleared inside a `@disable` or `@import` condition. + +A function or decorator that depends on a skipped value, such as `if()`, `eq()`, or `@sensitive=eq(exec("..."), yes)`, sees it as empty, so what it returns can differ from default mode. If `@currentEnv` itself points at a skipped value, `forEnv()` has no environment to compare against and reports an error. + +## Limits + +Shape mode does not sandbox plugin code. A plugin that is already installed or cached is still loaded, so its module code runs when it is imported, and its data types, decorators, and the `process()` hooks of its resolvers are registered and run as usual. If a tool opens schemas you don't trust, for example from a repository you just cloned, it should only do so where the plugins it finds are acceptable to run, or inside a sandbox. A plugin that is missing is never fetched or run. + +## Marking plugin resolvers as pure + +Plugin authors can opt a resolver into shape mode with `pure: true`. Set it only when the resolver reads nothing but its arguments and other items: no shell, network, filesystem, secret store, or randomness. + +```ts title="my-plugin.ts" +import { plugin } from 'varlock/plugin-lib'; + +plugin.registerResolverFunction({ + name: 'upper', + pure: true, + argsSchema: { type: 'array', arrayExactLength: 1 }, + async resolve() { + return String(await this.arrArgs[0].resolve()).toUpperCase(); + }, +}); +``` + +If you leave `pure` out, the resolver is skipped in shape mode, which is the safe default for anything that talks to a provider. Older varlock versions ignore the flag. diff --git a/packages/varlock-website/src/sidebar.ts b/packages/varlock-website/src/sidebar.ts index 1510b67bb..a61a1cb87 100644 --- a/packages/varlock-website/src/sidebar.ts +++ b/packages/varlock-website/src/sidebar.ts @@ -38,6 +38,7 @@ export const sidebar: StarlightUserConfig['sidebar'] = [ { label: 'Plugins', slug: 'guides/plugins' }, { label: 'Static vs dynamic', slug: 'guides/dynamic-config', badge: 'new' }, { label: 'Code generation', slug: 'guides/code-generation', badge: 'new' }, + { label: 'Shape mode', slug: 'guides/shape-mode', badge: 'new' }, ], }, { From 6794de39242b0c1a683a744c243a131f9af5b258 Mon Sep 17 00:00:00 2001 From: voiys Date: Sat, 24 Oct 2026 05:38:23 +0200 Subject: [PATCH 03/12] Shape mode: close the gaps the hostile schema corpus found - builtin variables no longer read the real shell env (an env passed in explicitly is still used), so CI variables cannot change results - generated code is only written inside the root schema's directory; a path that leaves it (.., absolute, or through a symlink) is a schema error - an import that is not a regular file (a named pipe) is a loading error instead of a read that never returns - guide: documents these, that plugin pure flags are trusted like the plugin code that already runs, and that schema regexes have no timeout Tests for each fail on the previous commit. --- .bumpy/env-graph-shape-mode.md | 2 +- .../src/content/docs/guides/shape-mode.mdx | 12 +++- .../varlock/src/env-graph/lib/data-source.ts | 4 ++ .../varlock/src/env-graph/lib/env-graph.ts | 31 ++++++++- .../src/env-graph/test/shape-mode.test.ts | 66 +++++++++++++++++++ 5 files changed, 109 insertions(+), 6 deletions(-) diff --git a/.bumpy/env-graph-shape-mode.md b/.bumpy/env-graph-shape-mode.md index 5877e0752..589c689df 100644 --- a/.bumpy/env-graph-shape-mode.md +++ b/.bumpy/env-graph-shape-mode.md @@ -2,4 +2,4 @@ varlock: minor --- -Add a shape mode for tools that only inspect a schema. `new EnvGraph({ mode: "shape" })` runs only pure resolvers, skips root decorator execution and the cache, never downloads plugins, and ignores `process.env` overrides. Plugin resolvers can opt in with `pure: true`. +Add a shape mode for tools that only inspect a schema. `new EnvGraph({ mode: "shape" })` runs only pure resolvers, skips root decorator execution and the cache, never downloads plugins, ignores the shell environment, keeps generated code inside the project, and refuses imports that are not regular files. Plugin resolvers can opt in with `pure: true`. diff --git a/packages/varlock-website/src/content/docs/guides/shape-mode.mdx b/packages/varlock-website/src/content/docs/guides/shape-mode.mdx index 714bae830..af2cd7915 100644 --- a/packages/varlock-website/src/content/docs/guides/shape-mode.mdx +++ b/packages/varlock-website/src/content/docs/guides/shape-mode.mdx @@ -53,11 +53,13 @@ This loads the schema file and the files it imports. Value files such as `.env.l | Root decorators | `execute()` runs | `execute()` is skipped, so values set by `@setValuesBulk` and items it creates with `createMissing=true` are not in the graph. Items referenced in their arguments (`$VAR`) are still resolved and validated | | Cache | `@cache` opens a store | no cache store is created | | Plugins | a pinned plugin is downloaded if missing | never downloaded. If it isn't installed or cached, loading reports a schema error | -| `process.env` | overrides item values | overrides are ignored. Values come from the files that were loaded, and builtin variables still read the environment | +| `process.env` | overrides item values, and builtin variables read it | ignored. Values come from the files that were loaded, and builtin variables only see an env you pass in explicitly (`graph.processEnvOverride`) | +| Code generation | writes wherever `path` points | only inside the directory of the root schema or directory; a path that leaves it (`..`, absolute, or through a symlink) is a schema error | +| Imports | any path | an imported path that is not a regular file (a named pipe or a device) is a loading error instead of a read that never returns | -Everything skipped is recorded in `graph.shapeSkipped` as `{ kind, name, itemKey? }`, where `kind` is `resolver`, `rootDecoratorExecute`, `cacheStore`, or `pluginDownload`, and `itemKey` is the item whose value was being resolved when a resolver was skipped. Tools can use it to tell users which values could not be computed. +Everything skipped is recorded in `graph.shapeSkipped` as `{ kind, name, itemKey? }`, where `kind` is `resolver`, `rootDecoratorExecute`, `cacheStore`, `pluginDownload`, or `codegenWrite`, and `itemKey` is the item whose value was being resolved when a resolver was skipped. Tools can use it to tell users which values could not be computed. -Apart from those differences, a tool sees the same types that [code generation](/guides/code-generation/) produces, and the same requiredness, sensitivity, and validation messages, except where a decorator or function depends on a value that was skipped (see [Pure resolvers](#pure-resolvers)). [Builtin variables](/reference/builtin-variables/) such as `VARLOCK_ENV` are still read from the environment, except that outside CI `VARLOCK_BRANCH` is unknown (and listed in `shapeSkipped`), because the local `git branch --show-current` lookup is skipped. In CI it is read from the platform's variables as usual. +Apart from those differences, a tool sees the same types that [code generation](/guides/code-generation/) produces, and the same requiredness, sensitivity, and validation messages, except where a decorator or function depends on a value that was skipped (see [Pure resolvers](#pure-resolvers)). [Builtin variables](/reference/builtin-variables/) such as `VARLOCK_ENV` are computed from an env you pass in explicitly, or from an empty one, so the same schema gives the same result on any machine. Without CI variables in that env, `VARLOCK_BRANCH` is unknown (and listed in `shapeSkipped`), because the local `git branch --show-current` lookup is skipped. ### Pure resolvers @@ -71,6 +73,10 @@ A function or decorator that depends on a skipped value, such as `if()`, `eq()`, Shape mode does not sandbox plugin code. A plugin that is already installed or cached is still loaded, so its module code runs when it is imported, and its data types, decorators, and the `process()` hooks of its resolvers are registered and run as usual. If a tool opens schemas you don't trust, for example from a repository you just cloned, it should only do so where the plugins it finds are acceptable to run, or inside a sandbox. A plugin that is missing is never fetched or run. +`pure` on a plugin resolver is a promise made by code that already runs, so shape mode trusts it the same way. A plugin manifest that lets tools skip importing plugin code would not carry that flag. + +A regex in the schema (for example in `regex()` or `@type=string(matches=...)`) runs in both modes, and JavaScript has no timeout for it, so a pattern with catastrophic backtracking can stall a load. Tools that open untrusted schemas on every keystroke should run the load in a worker they can stop. + ## Marking plugin resolvers as pure Plugin authors can opt a resolver into shape mode with `pure: true`. Set it only when the resolver reads nothing but its arguments and other items: no shell, network, filesystem, secret store, or randomness. diff --git a/packages/varlock/src/env-graph/lib/data-source.ts b/packages/varlock/src/env-graph/lib/data-source.ts index f6e7d4ef7..d966e994d 100644 --- a/packages/varlock/src/env-graph/lib/data-source.ts +++ b/packages/varlock/src/env-graph/lib/data-source.ts @@ -684,6 +684,10 @@ export abstract class EnvGraphDataSource { if (fsStat.isDirectory()) { this._errors.push(new LoadingError('Imported path is a directory, add trailing "/" to import')); return; + } else if (this.graph.mode === 'shape' && !fsStat.isFile()) { + // a named pipe or device would block the read forever, hanging every editor keystroke or CI lint + this._errors.push(new LoadingError(`Imported path is not a regular file: ${fullImportPath}`)); + return; } else if (!fileName.startsWith('.env.')) { this._errors.push(new LoadingError('imported file must be a .env.* file')); return; diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index a6657c7ad..b713d62f9 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -238,7 +238,7 @@ export class EnvGraph { */ readonly mode: 'default' | 'shape' = 'default'; shapeSkipped: Array<{ - kind: 'resolver' | 'rootDecoratorExecute' | 'cacheStore' | 'pluginDownload', + kind: 'resolver' | 'rootDecoratorExecute' | 'cacheStore' | 'pluginDownload' | 'codegenWrite', name: string, /** the item being resolved when a resolver was skipped, when known */ itemKey?: string, @@ -542,12 +542,15 @@ export class EnvGraph { /** Cached CI env info, computed lazily from processEnvOverride or real process.env */ private _cachedCiEnv?: CiEnvInfo; get ciEnvInfo(): CiEnvInfo { - this._cachedCiEnv ??= getCiEnv(this.processEnvOverride ?? process.env); + this._cachedCiEnv ??= getCiEnv(this.processEnvForBuiltins); return this._cachedCiEnv; } /** The process env record used for builtin var detection */ get processEnvForBuiltins(): Record { + // shape mode never reads the real shell env, so ambient CI variables cannot change what a schema means; + // an env the caller passes in explicitly is a deliberate input and is still used + if (this.mode === 'shape') return this.processEnvOverride ?? {}; return this.processEnvOverride ?? process.env; } @@ -1482,6 +1485,12 @@ export class EnvGraph { ? path.resolve(dec.dataSource.fullPath, '..') : process.cwd(); const outputPath = path.resolve(sourceDir, settings.obj.path); + // shape mode reads schemas nobody has reviewed: a schema must not be able to write outside the project + // it was loaded from (`path=../..`, an absolute path, or a symlink pointing out of it) + if (this.mode === 'shape' && !await this.isWritableInShapeLoad(outputPath)) { + this.shapeSkipped.push({ kind: 'codegenWrite', name: outputPath }); + throw new SchemaError(`@${decoratorName} - in shape mode, \`path\` must stay inside the project (${outputPath})`); + } const filterStr: string | undefined = settings.obj.filter; if (!fieldsByFilterStr.has(filterStr ?? '')) { @@ -1519,6 +1528,24 @@ export class EnvGraph { return { generatedCount, skippedImportOnlyCount }; } + /** + * Shape mode only writes generated code inside the directory of the root schema: the nearest existing ancestor + * of the target is resolved through symlinks and must be inside it, and the target itself must not be a symlink. + */ + private async isWritableInShapeLoad(outputPath: string) { + const root = this.rootDataSource; + let rootDir = this.basePath ?? process.cwd(); + if (root instanceof FileBasedDataSource) rootDir = path.dirname(root.fullPath); + else if (root && 'basePath' in root && typeof root.basePath === 'string') rootDir = root.basePath; // DirectoryDataSource + const realRoot = await fs.promises.realpath(rootDir).catch(() => path.resolve(rootDir)); + let existing = path.dirname(outputPath); + while (!fs.existsSync(existing) && path.dirname(existing) !== existing) existing = path.dirname(existing); + const realParent = await fs.promises.realpath(existing).catch(() => existing); + const inside = realParent === realRoot || realParent.startsWith(realRoot + path.sep); + const targetIsLink = await fs.promises.lstat(outputPath).then((st) => st.isSymbolicLink(), () => false); + return inside && !targetIsLink; + } + /** * The `@redact` root decorator, or its deprecated alias `@redactLogs`. Using the alias * warns; using both is an error (on the alias), and `@redact` wins. diff --git a/packages/varlock/src/env-graph/test/shape-mode.test.ts b/packages/varlock/src/env-graph/test/shape-mode.test.ts index af2659773..00fe1ba68 100644 --- a/packages/varlock/src/env-graph/test/shape-mode.test.ts +++ b/packages/varlock/src/env-graph/test/shape-mode.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect, vi, afterEach, } from 'vitest'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -409,4 +410,69 @@ describe('EnvGraph shape mode', () => { }; expect(await info('shape')).toEqual(await info('default')); }); + + test('builtin vars ignore CI variables in the shell', async () => { + vi.stubEnv('GITHUB_ACTIONS', 'true'); + vi.stubEnv('GITHUB_REF_NAME', 'branch-from-shell'); + try { + const schema = '# ---\nB=$VARLOCK_BRANCH'; + const { g } = await load(schema, 'shape', { resolve: true }); + expect(g.configSchema.B.resolvedValue).toBeUndefined(); + const { g: d } = await load(schema, 'default', { resolve: true }); + expect(d.configSchema.B.resolvedValue).toBe('branch-from-shell'); // control + } finally { + vi.unstubAllEnvs(); + } + }); + + describe('code generation stays inside the project', () => { + const project = () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-shape-codegen-')); + const dir = path.join(base, 'project'); + fs.mkdirSync(dir); + return { base, dir }; + }; + const generate = async (dir: string, mode: 'default' | 'shape', outPath: string) => { + const g = new EnvGraph({ mode }); + const schemaPath = path.join(dir, '.env.schema'); + fs.writeFileSync(schemaPath, `# @generateTsTypes(path=${outPath})\n# ---\nA=1\n`); + await g.setRootDataSource(new DotEnvFileDataSource(schemaPath)); + await g.finishLoad(); + return g.runCodeGeneratorsIfNeeded(); + }; + + test('a path outside the project is refused', async () => { + const { base, dir } = project(); + await expect(generate(dir, 'shape', '../escape.d.ts')).rejects.toThrow(/must stay inside the project/); + expect(fs.existsSync(path.join(base, 'escape.d.ts'))).toBe(false); + await generate(dir, 'default', '../escape.d.ts'); + expect(fs.existsSync(path.join(base, 'escape.d.ts'))).toBe(true); // control + }); + + test('a symlinked directory pointing out of the project is refused', async () => { + const { base, dir } = project(); + fs.mkdirSync(path.join(base, 'outside')); + fs.symlinkSync(path.join(base, 'outside'), path.join(dir, 'link')); + await expect(generate(dir, 'shape', 'link/env.d.ts')).rejects.toThrow(/must stay inside the project/); + expect(fs.existsSync(path.join(base, 'outside', 'env.d.ts'))).toBe(false); + }); + + test('a path inside the project is written', async () => { + const { dir } = project(); + await generate(dir, 'shape', 'types/env.d.ts'); + expect(fs.existsSync(path.join(dir, 'types', 'env.d.ts'))).toBe(true); + }); + }); + + test.skipIf(process.platform === 'win32')('an import that is not a regular file (a named pipe) is refused instead of hanging', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-shape-fifo-')); + execFileSync('mkfifo', [path.join(dir, '.env.pipe')]); + const g = new EnvGraph({ mode: 'shape' }); + const schemaPath = path.join(dir, '.env.schema'); + fs.writeFileSync(schemaPath, '# @import(./.env.pipe)\n# ---\nA=1\n'); + const source = new DotEnvFileDataSource(schemaPath); + await g.setRootDataSource(source); + expect(source.errors.map((e) => e.message).join('\n')).toMatch(/not a regular file/); + // no default-mode control: there the read blocks forever + }, 5000); }); From 114bc01c2a8a0078a75de654804a529232746f85 Mon Sep 17 00:00:00 2001 From: voiys Date: Sat, 24 Oct 2026 05:59:13 +0200 Subject: [PATCH 04/12] Shape mode: refuse non-regular files on every read, keep errors from known parts - the named-pipe check moves from the direct-import branch to FileBasedDataSource, so a pipe found through a directory import, or the root schema itself, is refused too instead of hanging the load - an error is only treated as caused by a partial value when the resolver it was first thrown from had an unknown input, so an error from a fully known part of the same expression is still reported - the CI-branch test also checks that git is not run Tests for the pipe cases and the mixed expression fail on the previous commit. --- .bumpy/env-graph-shape-mode.md | 2 +- .../src/content/docs/guides/shape-mode.mdx | 4 +- .../varlock/src/env-graph/lib/config-item.ts | 6 +- .../varlock/src/env-graph/lib/data-source.ts | 10 ++-- .../varlock/src/env-graph/lib/resolver.ts | 6 ++ .../src/env-graph/test/shape-mode.test.ts | 59 +++++++++++++++---- 6 files changed, 65 insertions(+), 22 deletions(-) diff --git a/.bumpy/env-graph-shape-mode.md b/.bumpy/env-graph-shape-mode.md index 589c689df..0d1ed8846 100644 --- a/.bumpy/env-graph-shape-mode.md +++ b/.bumpy/env-graph-shape-mode.md @@ -2,4 +2,4 @@ varlock: minor --- -Add a shape mode for tools that only inspect a schema. `new EnvGraph({ mode: "shape" })` runs only pure resolvers, skips root decorator execution and the cache, never downloads plugins, ignores the shell environment, keeps generated code inside the project, and refuses imports that are not regular files. Plugin resolvers can opt in with `pure: true`. +Add a shape mode for tools that only inspect a schema. `new EnvGraph({ mode: "shape" })` runs only pure resolvers, skips root decorator execution and the cache, never downloads plugins, ignores the shell environment, keeps generated code inside the project, and refuses schema files that are not regular files. Plugin resolvers can opt in with `pure: true`. diff --git a/packages/varlock-website/src/content/docs/guides/shape-mode.mdx b/packages/varlock-website/src/content/docs/guides/shape-mode.mdx index af2cd7915..8cd6de301 100644 --- a/packages/varlock-website/src/content/docs/guides/shape-mode.mdx +++ b/packages/varlock-website/src/content/docs/guides/shape-mode.mdx @@ -55,7 +55,7 @@ This loads the schema file and the files it imports. Value files such as `.env.l | Plugins | a pinned plugin is downloaded if missing | never downloaded. If it isn't installed or cached, loading reports a schema error | | `process.env` | overrides item values, and builtin variables read it | ignored. Values come from the files that were loaded, and builtin variables only see an env you pass in explicitly (`graph.processEnvOverride`) | | Code generation | writes wherever `path` points | only inside the directory of the root schema or directory; a path that leaves it (`..`, absolute, or through a symlink) is a schema error | -| Imports | any path | an imported path that is not a regular file (a named pipe or a device) is a loading error instead of a read that never returns | +| Files | read as usual | a schema or env file that is not a regular file (a named pipe or a device), whether it is the root file, imported directly, or found in an imported directory, is a loading error instead of a read that never returns | Everything skipped is recorded in `graph.shapeSkipped` as `{ kind, name, itemKey? }`, where `kind` is `resolver`, `rootDecoratorExecute`, `cacheStore`, `pluginDownload`, or `codegenWrite`, and `itemKey` is the item whose value was being resolved when a resolver was skipped. Tools can use it to tell users which values could not be computed. @@ -65,7 +65,7 @@ Apart from those differences, a tool sees the same types that [code generation]( These built-in resolvers only read other values, so they still run: `concat()`, `fallback()`, `ref()`, `regex()`, `remap()`, `forEnv()`, `eq()`, `if()`, `ifs()`, `not()`, `and()`, `or()`, `isEmpty()`, `domainFromUrl()`, and `inferFromPrefix()`. That is enough to evaluate `@currentEnv`, `@required=forEnv(production)`, and `${REF}` expansion. -Everything else resolves to `undefined`: [`exec()`](/reference/functions/#exec), the random generators, [`generateOtp()`](/reference/functions/#generateotp), [`cache()`](/reference/functions/#cache), and plugin resolvers that are not [marked pure](#marking-plugin-resolvers-as-pure). An item whose value depends on one of them (directly, or through a reference like `COPY=$TOKEN`) is unknown. It is left unset and is not coerced, validated, or reported as a missing required value. A `@disable` or `@import(..., enabled=...)` condition that depends on one is treated as enabled, so the file's items stay in the graph. An error that a resolver raises on a partial value is cleared for an item's value, so an unrelated real error inside the same expression is hidden too. It is not cleared inside a `@disable` or `@import` condition. +Everything else resolves to `undefined`: [`exec()`](/reference/functions/#exec), the random generators, [`generateOtp()`](/reference/functions/#generateotp), [`cache()`](/reference/functions/#cache), and plugin resolvers that are not [marked pure](#marking-plugin-resolvers-as-pure). An item whose value depends on one of them (directly, or through a reference like `COPY=$TOKEN`) is unknown. It is left unset and is not coerced, validated, or reported as a missing required value. A `@disable` or `@import(..., enabled=...)` condition that depends on one is treated as enabled, so the file's items stay in the graph. An error that a resolver raises because one of its own inputs is unknown (for example `domainFromUrl()` of a half-built URL) is cleared for an item's value. An error from a part of the expression that doesn't depend on a skipped resolver is still reported, so `concat(exec(...), domainFromUrl("mailto:x"))` still shows the `domainFromUrl()` error. It is not cleared inside a `@disable` or `@import` condition. A function or decorator that depends on a skipped value, such as `if()`, `eq()`, or `@sensitive=eq(exec("..."), yes)`, sees it as empty, so what it returns can differ from default mode. If `@currentEnv` itself points at a skipped value, `forEnv()` has no environment to compare against and reports an error. diff --git a/packages/varlock/src/env-graph/lib/config-item.ts b/packages/varlock/src/env-graph/lib/config-item.ts index d01127fc8..748ac33cb 100644 --- a/packages/varlock/src/env-graph/lib/config-item.ts +++ b/packages/varlock/src/env-graph/lib/config-item.ts @@ -1102,6 +1102,7 @@ export class ConfigItem { return; } + let errorFromUnknownInput = false; if (!this.valueResolver) { this.isResolved = true; this.resolvedRawValue = undefined; @@ -1110,6 +1111,7 @@ export class ConfigItem { try { this.resolvedRawValue = await this.valueResolver.resolve(); } catch (err) { + errorFromUnknownInput = !!(err as any)?._unknownInputInShapeLoad; if (err instanceof ResolutionError) { this.resolutionError = err; } else { @@ -1120,8 +1122,8 @@ export class ConfigItem { } // shape mode: a resolver that failed on a partial value (e.g. `domainFromUrl()` of a half-built url) - // did not really fail, the value is just unknown - if (this.resolutionError && this.isValueUnknownInShapeLoad) this.resolutionError = undefined; + // did not really fail, the value is just unknown. Errors from fully known parts of the value are kept + if (this.resolutionError && errorFromUnknownInput) this.resolutionError = undefined; if (this.resolvedRawValue instanceof RegExp) { this.resolutionError = new ResolutionError('Regex values are meant to be used within function args (e.g., remap()), not as a final resolved value'); diff --git a/packages/varlock/src/env-graph/lib/data-source.ts b/packages/varlock/src/env-graph/lib/data-source.ts index d966e994d..137463d1c 100644 --- a/packages/varlock/src/env-graph/lib/data-source.ts +++ b/packages/varlock/src/env-graph/lib/data-source.ts @@ -684,10 +684,6 @@ export abstract class EnvGraphDataSource { if (fsStat.isDirectory()) { this._errors.push(new LoadingError('Imported path is a directory, add trailing "/" to import')); return; - } else if (this.graph.mode === 'shape' && !fsStat.isFile()) { - // a named pipe or device would block the read forever, hanging every editor keystroke or CI lint - this._errors.push(new LoadingError(`Imported path is not a regular file: ${fullImportPath}`)); - return; } else if (!fileName.startsWith('.env.')) { this._errors.push(new LoadingError('imported file must be a .env.* file')); return; @@ -949,6 +945,12 @@ export abstract class FileBasedDataSource extends EnvGraphDataSource { this._errors.push(new LoadingError(`File does not exist: ${this.fullPath}`)); return; } + // shape mode: a named pipe or device would block the read forever, hanging every editor keystroke or + // CI lint. Checked here so it covers direct imports, directory imports and auto-loaded files alike + if (this.graph?.mode === 'shape' && !(await fs.stat(this.fullPath)).isFile()) { + this._errors.push(new LoadingError(`Not a regular file: ${this.fullPath}`)); + return; + } this.rawContents = await fs.readFile(this.fullPath, 'utf8'); } if (this.rawContents) await this._parseContents(); diff --git a/packages/varlock/src/env-graph/lib/resolver.ts b/packages/varlock/src/env-graph/lib/resolver.ts index 773ea444d..d57907411 100644 --- a/packages/varlock/src/env-graph/lib/resolver.ts +++ b/packages/varlock/src/env-graph/lib/resolver.ts @@ -275,6 +275,12 @@ export class Resolver { const resolvedValue = await this.def.resolve.call(this, this.meta); return resolvedValue; } catch (err) { + // shape mode: note, where the error was first thrown, whether this resolver's inputs were unknown. Only + // then is it an error about a partial value (e.g. `domainFromUrl()` of a half-built url); an error from a + // part of the expression that was fully known is real + if (graph?.mode === 'shape' && err && typeof err === 'object' && !('_unknownInputInShapeLoad' in err)) { + (err as any)._unknownInputInShapeLoad = this.resultUnknownInShapeLoad; + } if (err instanceof VarlockError) { // prefix error message with resolver function name (matching schema error behavior) // only prefix if the error wasn't already prefixed by a child resolver diff --git a/packages/varlock/src/env-graph/test/shape-mode.test.ts b/packages/varlock/src/env-graph/test/shape-mode.test.ts index 00fe1ba68..a59585aab 100644 --- a/packages/varlock/src/env-graph/test/shape-mode.test.ts +++ b/packages/varlock/src/env-graph/test/shape-mode.test.ts @@ -1,5 +1,5 @@ import { - describe, test, expect, vi, afterEach, + afterEach, beforeEach, describe, test, expect, vi, } from 'vitest'; import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; @@ -187,6 +187,9 @@ describe('EnvGraph shape mode', () => { LABEL=concat("domain: ", $DOMAIN) `, 'shape', { resolve: true }); for (const key of ['HOST', 'DOMAIN', 'LABEL']) expect(nested.g.configSchema[key].errors).toEqual([]); + // but an error from a part of the value that does not depend on the skipped resolver is real, and is kept + const mixed = await load('MIXED=concat(exec("echo x"), domainFromUrl("mailto:user@example.com"))', 'shape', { resolve: true }); + expect(mixed.g.configSchema.MIXED.errors.map((e) => e.message).join('\n')).toMatch(/domainFromUrl\(\)/); const { g: d } = await load(schema, 'default', { resolve: true }); expect(d.configSchema.API.resolvedValue).toBe('https://example.com'); @@ -300,9 +303,11 @@ describe('EnvGraph shape mode', () => { expect(fs.existsSync(marker)).toBe(true); // control expect(d.configSchema.VARLOCK_BRANCH.resolvedValue).toBe('fake-branch'); - // a branch reported by the CI platform is only read from the environment, so it still works + // a branch reported by the CI platform is only read from the environment, so it still works without git + fs.rmSync(marker, { force: true }); const ci = await load(schema, 'shape', { resolve: true, processEnv: { GITHUB_ACTIONS: 'true', GITHUB_REF_NAME: 'my-branch' } }); expect(ci.g.configSchema.VARLOCK_BRANCH.resolvedValue).toBe('my-branch'); + expect(fs.existsSync(marker)).toBe(false); fs.rmSync(marker, { force: true }); fs.rmSync(binDir, { recursive: true, force: true }); }); @@ -464,15 +469,43 @@ describe('EnvGraph shape mode', () => { }); }); - test.skipIf(process.platform === 'win32')('an import that is not a regular file (a named pipe) is refused instead of hanging', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-shape-fifo-')); - execFileSync('mkfifo', [path.join(dir, '.env.pipe')]); - const g = new EnvGraph({ mode: 'shape' }); - const schemaPath = path.join(dir, '.env.schema'); - fs.writeFileSync(schemaPath, '# @import(./.env.pipe)\n# ---\nA=1\n'); - const source = new DotEnvFileDataSource(schemaPath); - await g.setRootDataSource(source); - expect(source.errors.map((e) => e.message).join('\n')).toMatch(/not a regular file/); - // no default-mode control: there the read blocks forever - }, 5000); + // a file that is not a regular file (a named pipe) is refused instead of hanging, however it is reached + describe.skipIf(process.platform === 'win32')('non-regular files', () => { + const allErrors = (g: EnvGraph) => g.sortedDataSources.flatMap((s) => s.errors.map((e) => e.message)).join('\n'); + let dir: string; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-shape-fifo-')); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('a direct import', async () => { + execFileSync('mkfifo', [path.join(dir, '.env.pipe')]); + const schemaPath = path.join(dir, '.env.schema'); + fs.writeFileSync(schemaPath, '# @import(./.env.pipe)\n# ---\nA=1\n'); + const g = new EnvGraph({ mode: 'shape' }); + await g.setRootDataSource(new DotEnvFileDataSource(schemaPath)); + expect(allErrors(g)).toMatch(/Not a regular file: .*\.env\.pipe/); + // no default-mode control: there the read blocks forever + }, 5000); + + test('a directory import whose schema is a pipe', async () => { + fs.mkdirSync(path.join(dir, 'child')); + execFileSync('mkfifo', [path.join(dir, 'child', '.env.schema')]); + const schemaPath = path.join(dir, '.env.schema'); + fs.writeFileSync(schemaPath, '# @import(./child/)\n# ---\nA=1\n'); + const g = new EnvGraph({ mode: 'shape' }); + await g.setRootDataSource(new DotEnvFileDataSource(schemaPath)); + expect(allErrors(g)).toMatch(/Not a regular file: .*child.*\.env\.schema/); + }, 5000); + + test('the root schema itself', async () => { + const schemaPath = path.join(dir, '.env.schema'); + execFileSync('mkfifo', [schemaPath]); + const g = new EnvGraph({ mode: 'shape' }); + await g.setRootDataSource(new DotEnvFileDataSource(schemaPath)); + expect(allErrors(g)).toMatch(/Not a regular file/); + }, 5000); + }); }); From 93d512f2b0272cb3a98c6edfe28ee362a8c5b46e Mon Sep 17 00:00:00 2001 From: voiys Date: Sat, 24 Oct 2026 10:12:50 +0200 Subject: [PATCH 05/12] Shape mode: store whether a value is unknown, stat each file once - ConfigItem keeps the unknown flag computed when its value resolver ran, so each dependent read no longer walks the resolver tree - FileBasedDataSource does one stat instead of an access check plus a stat --- packages/varlock/src/env-graph/lib/config-item.ts | 6 +++++- packages/varlock/src/env-graph/lib/data-source.ts | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/varlock/src/env-graph/lib/config-item.ts b/packages/varlock/src/env-graph/lib/config-item.ts index 748ac33cb..ae509cab4 100644 --- a/packages/varlock/src/env-graph/lib/config-item.ts +++ b/packages/varlock/src/env-graph/lib/config-item.ts @@ -866,8 +866,10 @@ export class ConfigItem { * considered, not decorators, so an empty item with a skipped `@sensitive` still fails a required check. */ get isValueUnknownInShapeLoad(): boolean { - return this.envGraph.mode === 'shape' && !!this.valueResolver?.resultUnknownInShapeLoad; + return this._valueUnknownInShapeLoad; } + /** set once the value resolver has run, so dependents don't re-walk the resolver tree on every read */ + private _valueUnknownInShapeLoad = false; /** * Record the error for a required item with no value. A `@dynamic=boot` item is exempt when @@ -1072,6 +1074,7 @@ export class ConfigItem { this.resolvedRawValue = undefined; this.resolvedValue = undefined; this._resolvedDataType = undefined; + this._valueUnknownInShapeLoad = false; } if (this.isResolved) { // previously we would throw an error, now we resolve the envFlag early, so we can just return @@ -1119,6 +1122,7 @@ export class ConfigItem { this.resolutionError.cause = err; } } + if (this.envGraph.mode === 'shape') this._valueUnknownInShapeLoad = this.valueResolver.resultUnknownInShapeLoad; } // shape mode: a resolver that failed on a partial value (e.g. `domainFromUrl()` of a half-built url) diff --git a/packages/varlock/src/env-graph/lib/data-source.ts b/packages/varlock/src/env-graph/lib/data-source.ts index 137463d1c..f24bb6de0 100644 --- a/packages/varlock/src/env-graph/lib/data-source.ts +++ b/packages/varlock/src/env-graph/lib/data-source.ts @@ -941,13 +941,14 @@ export abstract class FileBasedDataSource extends EnvGraphDataSource { // no async constructors... :( async _finishInit() { if (!this.rawContents) { - if (!await pathExists(this.fullPath)) { + const fileStat = await fs.stat(this.fullPath).catch(() => undefined); + if (!fileStat) { this._errors.push(new LoadingError(`File does not exist: ${this.fullPath}`)); return; } // shape mode: a named pipe or device would block the read forever, hanging every editor keystroke or // CI lint. Checked here so it covers direct imports, directory imports and auto-loaded files alike - if (this.graph?.mode === 'shape' && !(await fs.stat(this.fullPath)).isFile()) { + if (this.graph?.mode === 'shape' && !fileStat.isFile()) { this._errors.push(new LoadingError(`Not a regular file: ${this.fullPath}`)); return; } From ef0895957194b41286b90b31bed3624b3227245b Mon Sep 17 00:00:00 2001 From: voiys Date: Tue, 10 Nov 2026 00:18:56 +0100 Subject: [PATCH 06/12] Shape mode: no plugin from a file of unknown presence, apply known bulk values A file kept only because its @import enabled= or @disable condition could not be evaluated might be skipped entirely by a normal load, so a @plugin in it (or in a file it imports) is no longer loaded. @setValuesBulk now runs in shape mode: it only resolves its args and edits the graph. Data written in the schema is applied as in a normal load. When the data or its enabled= condition comes from a skipped resolver, the items it could set are unknown while empty instead of failing the required check. A local plugin whose package.json is a named pipe or device is refused in both modes instead of blocking the read forever. --- .bumpy/env-graph-shape-mode.md | 2 +- .../src/content/docs/guides/shape-mode.mdx | 10 +- .../varlock/src/env-graph/lib/config-item.ts | 7 + .../varlock/src/env-graph/lib/data-source.ts | 26 +++- .../varlock/src/env-graph/lib/decorators.ts | 28 +++- .../varlock/src/env-graph/lib/env-graph.ts | 9 +- packages/varlock/src/env-graph/lib/plugins.ts | 18 ++- .../src/env-graph/test/shape-mode.test.ts | 123 ++++++++++++++++++ 8 files changed, 211 insertions(+), 12 deletions(-) diff --git a/.bumpy/env-graph-shape-mode.md b/.bumpy/env-graph-shape-mode.md index 0d1ed8846..607550e59 100644 --- a/.bumpy/env-graph-shape-mode.md +++ b/.bumpy/env-graph-shape-mode.md @@ -2,4 +2,4 @@ varlock: minor --- -Add a shape mode for tools that only inspect a schema. `new EnvGraph({ mode: "shape" })` runs only pure resolvers, skips root decorator execution and the cache, never downloads plugins, ignores the shell environment, keeps generated code inside the project, and refuses schema files that are not regular files. Plugin resolvers can opt in with `pure: true`. +Add a shape mode for tools that only inspect a schema. `new EnvGraph({ mode: "shape" })` runs only pure resolvers, skips root decorator execution and the cache, never downloads plugins, ignores the shell environment, keeps generated code inside the project, and refuses schema files that are not regular files. `@setValuesBulk` is applied when its data is written in the schema, and a file that is only loaded because its condition could not be evaluated never loads a plugin. Plugin resolvers can opt in with `pure: true`. diff --git a/packages/varlock-website/src/content/docs/guides/shape-mode.mdx b/packages/varlock-website/src/content/docs/guides/shape-mode.mdx index 8cd6de301..9ed1af4e7 100644 --- a/packages/varlock-website/src/content/docs/guides/shape-mode.mdx +++ b/packages/varlock-website/src/content/docs/guides/shape-mode.mdx @@ -50,14 +50,14 @@ This loads the schema file and the files it imports. Value files such as `.env.l | | Default mode | Shape mode | |---|---|---| | Resolver functions | all run | only [pure](#pure-resolvers) ones run, the rest resolve to `undefined` | -| Root decorators | `execute()` runs | `execute()` is skipped, so values set by `@setValuesBulk` and items it creates with `createMissing=true` are not in the graph. Items referenced in their arguments (`$VAR`) are still resolved and validated | +| Root decorators | `execute()` runs | `execute()` is skipped, except for `@setValuesBulk` (see [Bulk values](#bulk-values)). Items referenced in their arguments (`$VAR`) are still resolved and validated | | Cache | `@cache` opens a store | no cache store is created | | Plugins | a pinned plugin is downloaded if missing | never downloaded. If it isn't installed or cached, loading reports a schema error | | `process.env` | overrides item values, and builtin variables read it | ignored. Values come from the files that were loaded, and builtin variables only see an env you pass in explicitly (`graph.processEnvOverride`) | | Code generation | writes wherever `path` points | only inside the directory of the root schema or directory; a path that leaves it (`..`, absolute, or through a symlink) is a schema error | | Files | read as usual | a schema or env file that is not a regular file (a named pipe or a device), whether it is the root file, imported directly, or found in an imported directory, is a loading error instead of a read that never returns | -Everything skipped is recorded in `graph.shapeSkipped` as `{ kind, name, itemKey? }`, where `kind` is `resolver`, `rootDecoratorExecute`, `cacheStore`, `pluginDownload`, or `codegenWrite`, and `itemKey` is the item whose value was being resolved when a resolver was skipped. Tools can use it to tell users which values could not be computed. +Everything skipped is recorded in `graph.shapeSkipped` as `{ kind, name, itemKey? }`, where `kind` is `resolver`, `rootDecoratorExecute`, `cacheStore`, `pluginDownload`, `pluginLoad`, or `codegenWrite`, and `itemKey` is the item whose value was being resolved when a resolver was skipped. Tools can use it to tell users which values could not be computed. Apart from those differences, a tool sees the same types that [code generation](/guides/code-generation/) produces, and the same requiredness, sensitivity, and validation messages, except where a decorator or function depends on a value that was skipped (see [Pure resolvers](#pure-resolvers)). [Builtin variables](/reference/builtin-variables/) such as `VARLOCK_ENV` are computed from an env you pass in explicitly, or from an empty one, so the same schema gives the same result on any machine. Without CI variables in that env, `VARLOCK_BRANCH` is unknown (and listed in `shapeSkipped`), because the local `git branch --show-current` lookup is skipped. @@ -65,10 +65,14 @@ Apart from those differences, a tool sees the same types that [code generation]( These built-in resolvers only read other values, so they still run: `concat()`, `fallback()`, `ref()`, `regex()`, `remap()`, `forEnv()`, `eq()`, `if()`, `ifs()`, `not()`, `and()`, `or()`, `isEmpty()`, `domainFromUrl()`, and `inferFromPrefix()`. That is enough to evaluate `@currentEnv`, `@required=forEnv(production)`, and `${REF}` expansion. -Everything else resolves to `undefined`: [`exec()`](/reference/functions/#exec), the random generators, [`generateOtp()`](/reference/functions/#generateotp), [`cache()`](/reference/functions/#cache), and plugin resolvers that are not [marked pure](#marking-plugin-resolvers-as-pure). An item whose value depends on one of them (directly, or through a reference like `COPY=$TOKEN`) is unknown. It is left unset and is not coerced, validated, or reported as a missing required value. A `@disable` or `@import(..., enabled=...)` condition that depends on one is treated as enabled, so the file's items stay in the graph. An error that a resolver raises because one of its own inputs is unknown (for example `domainFromUrl()` of a half-built URL) is cleared for an item's value. An error from a part of the expression that doesn't depend on a skipped resolver is still reported, so `concat(exec(...), domainFromUrl("mailto:x"))` still shows the `domainFromUrl()` error. It is not cleared inside a `@disable` or `@import` condition. +Everything else resolves to `undefined`: [`exec()`](/reference/functions/#exec), the random generators, [`generateOtp()`](/reference/functions/#generateotp), [`cache()`](/reference/functions/#cache), and plugin resolvers that are not [marked pure](#marking-plugin-resolvers-as-pure). An item whose value depends on one of them (directly, or through a reference like `COPY=$TOKEN`) is unknown. It is left unset and is not coerced, validated, or reported as a missing required value. A `@disable` or `@import(..., enabled=...)` condition that depends on one is treated as enabled, so the file's items stay in the graph. A normal load might skip that file entirely, so a `@plugin` in it (or in a file it imports) is not loaded: the decorator gets a warning and the plugin is recorded in `shapeSkipped` as `pluginLoad`. An error that a resolver raises because one of its own inputs is unknown (for example `domainFromUrl()` of a half-built URL) is cleared for an item's value. An error from a part of the expression that doesn't depend on a skipped resolver is still reported, so `concat(exec(...), domainFromUrl("mailto:x"))` still shows the `domainFromUrl()` error. It is not cleared inside a `@disable` or `@import` condition. A function or decorator that depends on a skipped value, such as `if()`, `eq()`, or `@sensitive=eq(exec("..."), yes)`, sees it as empty, so what it returns can differ from default mode. If `@currentEnv` itself points at a skipped value, `forEnv()` has no environment to compare against and reports an error. +### Bulk values + +`@setValuesBulk` only resolves its arguments and edits the graph, so shape mode runs it. When the data is written in the schema, the values are set and `createMissing=true` adds items, the same as in a normal load. When the data or the `enabled` condition comes from a skipped resolver (`@setValuesBulk(exec(...))`), nothing is set. Every existing item the data could set (all of them, or the ones matching `pick`/`omit`) is then unknown while it is empty, so it is not reported as a missing required value. Items that only `createMissing=true` would add are not in the graph. + ## Limits Shape mode does not sandbox plugin code. A plugin that is already installed or cached is still loaded, so its module code runs when it is imported, and its data types, decorators, and the `process()` hooks of its resolvers are registered and run as usual. If a tool opens schemas you don't trust, for example from a repository you just cloned, it should only do so where the plugins it finds are acceptable to run, or inside a sandbox. A plugin that is missing is never fetched or run. diff --git a/packages/varlock/src/env-graph/lib/config-item.ts b/packages/varlock/src/env-graph/lib/config-item.ts index ae509cab4..90303f972 100644 --- a/packages/varlock/src/env-graph/lib/config-item.ts +++ b/packages/varlock/src/env-graph/lib/config-item.ts @@ -870,6 +870,8 @@ export class ConfigItem { } /** set once the value resolver has run, so dependents don't re-walk the resolver tree on every read */ private _valueUnknownInShapeLoad = false; + /** @internal shape mode: a `@setValuesBulk` whose data could not be read might set this item */ + _maybeSetByUnknownBulkInShapeLoad = false; /** * Record the error for a required item with no value. A `@dynamic=boot` item is exempt when @@ -1138,6 +1140,11 @@ export class ConfigItem { this.isResolved = true; + // shape mode: bulk data we could not read might fill this item, so empty means unknown here too + if (this._maybeSetByUnknownBulkInShapeLoad && (this.resolvedRawValue === undefined || this.resolvedRawValue === '')) { + this._valueUnknownInShapeLoad = true; + } + // shape mode: a value built from a skipped resolver is unknown. Any value we computed is partial // (e.g. `concat("https://", $UNKNOWN)`), so don't coerce or validate it and leave it unset if (this.isValueUnknownInShapeLoad) { diff --git a/packages/varlock/src/env-graph/lib/data-source.ts b/packages/varlock/src/env-graph/lib/data-source.ts index f24bb6de0..9c9d7899b 100644 --- a/packages/varlock/src/env-graph/lib/data-source.ts +++ b/packages/varlock/src/env-graph/lib/data-source.ts @@ -103,7 +103,21 @@ export abstract class EnvGraphDataSource { importFilter?: KeyFilter, /** true when the @import had a non-static `enabled` parameter (e.g. `enabled=forEnv("dev")`) */ isConditionallyEnabled?: boolean, + /** @internal shape mode: `enabled` could not be evaluated, so the file was imported without knowing if it is */ + enabledUnknownInShapeLoad?: boolean, }; + /** @internal shape mode: `@disable` could not be evaluated, so the source was kept without knowing if it is */ + _disableUnknownInShapeLoad = false; + /** + * @internal shape mode: this source, or one that imports it, is only in the graph because the condition + * that enables or disables it could not be evaluated. A normal load might skip it entirely, so nothing + * in it may run code (see processPluginInstallDecorators). + */ + get isPresenceUnknownInShapeLoad(): boolean { + return this._disableUnknownInShapeLoad + || !!this.importMeta?.enabledUnknownInShapeLoad + || !!this.parent?.isPresenceUnknownInShapeLoad; + } get isImport(): boolean { return !!this.importMeta?.isImport || !!this.parent?.isImport; } @@ -354,7 +368,10 @@ export abstract class EnvGraphDataSource { let disabledVal = await disabledDec.resolve(); // shape mode: a condition built from a skipped resolver is unknown, so keep the source rather than // dropping its items from the graph - if (this.graph.mode === 'shape' && disabledDec.decValueResolver?.resultUnknownInShapeLoad) disabledVal = false; + if (this.graph.mode === 'shape' && disabledDec.decValueResolver?.resultUnknownInShapeLoad) { + disabledVal = false; + this._disableUnknownInShapeLoad = true; + } if (!_.isBoolean(disabledVal)) { this._errors.push(new LoadingError('expected @disable to be boolean value')); return; @@ -603,7 +620,12 @@ export abstract class EnvGraphDataSource { // Used by type generation to identify env-dependent sources const isConditionallyEnabled = !!enabledResolver && !enabledResolver.isStatic; - const importMeta = { isImport: true, importFilter, isConditionallyEnabled }; + const importMeta = { + isImport: true, + importFilter, + isConditionallyEnabled, + ...enabledUnknown && { enabledUnknownInShapeLoad: true }, + }; // Check if missing imports should be allowed (defaults to false if not specified) const allowMissing = importArgs.obj.allowMissing ?? false; diff --git a/packages/varlock/src/env-graph/lib/decorators.ts b/packages/varlock/src/env-graph/lib/decorators.ts index 10674d7af..1a3753d36 100644 --- a/packages/varlock/src/env-graph/lib/decorators.ts +++ b/packages/varlock/src/env-graph/lib/decorators.ts @@ -13,7 +13,9 @@ import { } from './resolver'; import { ResolutionError, SchemaError, type VarlockError } from './errors'; import type { EnvGraph } from './env-graph'; -import { parseKeyFilterArgs, applyKeyFilter, type KeyFilter } from './key-filter'; +import { + parseKeyFilterArgs, applyKeyFilter, keyMatchesFilter, type KeyFilter, +} from './key-filter'; import { parseDuration } from '../../lib/duration'; import { BUILT_IN_TRANSFORM_SCHEME_SPECS, PROXY_APPROVAL_EACH_VALUES, REMOVED_PROXY_RULE_OPTIONS, @@ -69,6 +71,10 @@ export abstract class DecoratorInstance { get isInert() { return !!this.decoratorDef?.inert; } + /** @internal whether an EnvGraph in `shape` mode may call execute(), see RootDecoratorDef.runsInShapeLoad */ + get runsInShapeLoad(): boolean { + return !!(this.decoratorDef as RootDecoratorDef | undefined)?.runsInShapeLoad; + } private processed = false; private processedData: any; @@ -346,6 +352,12 @@ export type RootDecoratorDef = { objectValueExample?: string; process?: (decoratorValue: Resolver) => (Processed | Promise); execute?: (executeInput: Processed) => void | Promise; + /** + * Set when `execute()` only resolves its own args and edits the graph, so an EnvGraph in `shape` mode can + * run it (the graph skips any arg resolver that reaches outside). It must cope with those args being + * unknown: see `Resolver.resultUnknownInShapeLoad`. Every other root decorator is never executed there. + */ + runsInShapeLoad?: boolean; useFnArgsResolver?: boolean, /** * Skip value-resolver creation entirely - for decorators whose raw parsed value is @@ -825,6 +837,7 @@ export const builtInRootDecorators: Array> = [ { name: 'setValuesBulk', isFunction: true, + runsInShapeLoad: true, process(argsVal) { if (!argsVal.arrArgs || argsVal.arrArgs.length === 0) { throw new SchemaError('@setValuesBulk requires at least one argument - the data resolver'); @@ -880,10 +893,22 @@ export const builtInRootDecorators: Array> = [ keyFilter?: KeyFilter; }; + // shape mode: when the data (or whether it applies at all) comes from a skipped resolver, we cannot know + // which items it sets. Every item it could set is marked, so an empty one reads as unknown, not unset. + // Items that only `createMissing` would add are not in the graph + const skipAsUnknownInShapeLoad = () => { + graph.shapeSkipped.push({ kind: 'rootDecoratorExecute', name: 'setValuesBulk' }); + for (const key of Object.keys(graph.configSchema)) { + if (keyFilter && !keyMatchesFilter(key, keyFilter)) continue; + graph.configSchema[key]._maybeSetByUnknownBulkInShapeLoad = true; + } + }; + // check enabled before resolving data - important so disabled sources don't trigger data fetching const enabledResolver = argsResolver.objArgs?.enabled; if (enabledResolver !== undefined) { const enabledValue = await enabledResolver.resolve(); + if (graph.mode === 'shape' && enabledResolver.resultUnknownInShapeLoad) return skipAsUnknownInShapeLoad(); if (!_.isBoolean(enabledValue)) { throw new SchemaError('@setValuesBulk: enabled must be a boolean'); } @@ -892,6 +917,7 @@ export const builtInRootDecorators: Array> = [ // resolve the args const resolved = await argsResolver.resolve() as { arr: Array, obj: Record }; + if (graph.mode === 'shape' && argsResolver.resultUnknownInShapeLoad) return skipAsUnknownInShapeLoad(); const dataString = resolved.arr[0]; const format = resolved.obj?.format as string | undefined; const createMissing = resolved.obj?.createMissing ?? false; diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index b713d62f9..25ccd229a 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -233,12 +233,13 @@ export class EnvGraph { /** * `shape` builds the graph for tools that only inspect a schema (type checkers, language * servers, CI linters): only pure resolvers run, root decorators are never executed, no cache - * store is created, plugins are never downloaded, and process.env is ignored. Anything skipped - * is recorded in `shapeSkipped`. + * store is created, plugins are never downloaded, and process.env is ignored. A file that is only + * loaded because its `enabled` or `@disable` condition could not be evaluated never loads a plugin. + * Anything skipped is recorded in `shapeSkipped`. */ readonly mode: 'default' | 'shape' = 'default'; shapeSkipped: Array<{ - kind: 'resolver' | 'rootDecoratorExecute' | 'cacheStore' | 'pluginDownload' | 'codegenWrite', + kind: 'resolver' | 'rootDecoratorExecute' | 'cacheStore' | 'pluginDownload' | 'pluginLoad' | 'codegenWrite', name: string, /** the item being resolved when a resolver was skipped, when known */ itemKey?: string, @@ -822,7 +823,7 @@ export class EnvGraph { await this.resolveEnvValues([...deps]); // shape mode still resolves the decorator's deps above (only pure resolvers run), which also // re-validates them with their final metadata; it only skips the side-effecting execute() - if (this.mode === 'shape') { + if (this.mode === 'shape' && !decInstance.runsInShapeLoad) { this.shapeSkipped.push({ kind: 'rootDecoratorExecute', name: decInstance.name }); continue; } diff --git a/packages/varlock/src/env-graph/lib/plugins.ts b/packages/varlock/src/env-graph/lib/plugins.ts index 358c074bc..8746adfe0 100644 --- a/packages/varlock/src/env-graph/lib/plugins.ts +++ b/packages/varlock/src/env-graph/lib/plugins.ts @@ -503,9 +503,14 @@ async function initPluginFromLocalPath(localPath: string) { // If it's a directory, load package.json and use exports field } else if (stats.isDirectory()) { const pkgJsonPath = path.join(localPath, 'package.json'); - if (!(await pathExists(pkgJsonPath))) { + const pkgJsonStat = await fs.stat(pkgJsonPath).catch(() => undefined); + if (!pkgJsonStat) { throw new SchemaError('Plugin is missing package.json file'); } + // reading a named pipe or a device would block forever + if (!pkgJsonStat.isFile()) { + throw new SchemaError(`Plugin package.json is not a regular file: ${pkgJsonPath}`); + } const packageJsonContents = JSON.parse(await fs.readFile(pkgJsonPath, 'utf-8')); if (!packageJsonContents.exports?.['./plugin']) { @@ -806,6 +811,17 @@ export async function processPluginInstallDecorators(dataSource: EnvGraphDataSou let pluginSrcPath: string | undefined; try { const installPluginArgs = await pluginDecorator.resolve(); + // shape mode: a normal load might not load this file at all (its `enabled` or `@disable` condition + // could not be evaluated), and loading a plugin runs its code, so the plugin is left out + if (graph.mode === 'shape' && dataSource.isPresenceUnknownInShapeLoad) { + const pluginName = String(installPluginArgs?.arr?.[0]); + graph.shapeSkipped.push({ kind: 'pluginLoad', name: pluginName }); + pluginDecorator._errors.push(new SchemaError( + `Plugin "${pluginName}" was not loaded: shape mode cannot tell whether this file is enabled`, + { isWarning: true, location: getErrorLocation(dataSource, pluginDecorator) }, + )); + continue; + } const pluginSourceDescriptor = installPluginArgs.arr[0]; if (!_.isString(pluginSourceDescriptor)) { throw new SchemaError('Bad @plugin - must provide a string source location'); diff --git a/packages/varlock/src/env-graph/test/shape-mode.test.ts b/packages/varlock/src/env-graph/test/shape-mode.test.ts index a59585aab..5ba58b080 100644 --- a/packages/varlock/src/env-graph/test/shape-mode.test.ts +++ b/packages/varlock/src/env-graph/test/shape-mode.test.ts @@ -470,6 +470,115 @@ describe('EnvGraph shape mode', () => { }); // a file that is not a regular file (a named pipe) is refused instead of hanging, however it is reached + describe('a file kept only because its condition is unknown', () => { + let dir: string; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-shape-maybe-')); + // a plugin that leaves a marker when its module code runs + fs.writeFileSync(path.join(dir, 'marker-plugin.cjs'), "require('node:fs').writeFileSync(require('node:path').join(__dirname, 'PLUGIN_RAN'), '');\n"); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + const pluginRan = () => fs.existsSync(path.join(dir, 'PLUGIN_RAN')); + const loadDir = async (mode: 'default' | 'shape') => { + const g = new EnvGraph({ mode }); + await g.setRootDataSource(new DotEnvFileDataSource(path.join(dir, '.env.schema'))); + await g.finishLoad(); + return g; + }; + + test('an @import that a normal load skips never loads its plugin', async () => { + fs.writeFileSync(path.join(dir, '.env.schema'), '# @import(./.env.extra, enabled=eq(exec("echo no"), "yes"))\n# ---\nROOT=1\n'); + fs.writeFileSync(path.join(dir, '.env.extra'), '# @plugin(./marker-plugin.cjs)\n# ---\nEXTRA=1\n'); + + const defaultGraph = await loadDir('default'); + expect(Object.keys(defaultGraph.configSchema)).toEqual(['ROOT']); + expect(pluginRan()).toBe(false); // a normal load never imports the file here + + const g = await loadDir('shape'); + expect(Object.keys(g.configSchema).sort()).toEqual(['EXTRA', 'ROOT']); // the items are kept + expect(pluginRan()).toBe(false); + expect(g.shapeSkipped).toContainEqual({ kind: 'pluginLoad', name: './marker-plugin.cjs' }); + }); + + test('a file with an unknown @disable never loads its plugin, nor does a file it imports', async () => { + fs.writeFileSync(path.join(dir, '.env.schema'), '# @import(./.env.extra)\n# ---\nROOT=1\n'); + fs.writeFileSync(path.join(dir, '.env.extra'), '# @disable=eq(exec("echo yes"), "yes")\n# @import(./.env.nested)\n# ---\nEXTRA=1\n'); + fs.writeFileSync(path.join(dir, '.env.nested'), '# @plugin(./marker-plugin.cjs)\n# ---\nNESTED=1\n'); + + const g = await loadDir('shape'); + expect(Object.keys(g.configSchema).sort()).toEqual(['EXTRA', 'NESTED', 'ROOT']); + expect(pluginRan()).toBe(false); + }); + + test('a file whose condition is known still loads its plugin', async () => { + fs.writeFileSync(path.join(dir, '.env.schema'), '# @import(./.env.extra, enabled=eq("yes", "yes"))\n# ---\nROOT=1\n'); + fs.writeFileSync(path.join(dir, '.env.extra'), '# @plugin(./marker-plugin.cjs)\n# ---\nEXTRA=1\n'); + await loadDir('shape'); + expect(pluginRan()).toBe(true); + }); + }); + + describe('@setValuesBulk', () => { + const summary = (g: EnvGraph) => Object.fromEntries(Object.entries(g.configSchema).map(([key, item]) => [ + key, + item.errors.some((e) => !e.isWarning) ? `error: ${item.errors.find((e) => !e.isWarning)!.message}` : item.resolvedValue, + ])); + + test('data written in the schema is applied, the same as in default mode', async () => { + const schema = outdent` + # @setValuesBulk('{"API_KEY":"abc","ADDED":"new"}', format=json, createMissing=true) + # @defaultSensitive=false + # --- + # @required + API_KEY= + # @required + OTHER= + `; + const expected = { + API_KEY: 'abc', + OTHER: 'error: Value is required but is currently empty', + ADDED: 'new', + }; + expect(summary((await load(schema, 'default', { resolve: true })).g)).toEqual(expected); // control + expect(summary((await load(schema, 'shape', { resolve: true })).g)).toEqual(expected); + }); + + test('data from a skipped resolver leaves the items it could set unknown, not missing', async () => { + const schema = outdent` + # @setValuesBulk(exec("echo '{}'"), format=json, pick=[API_*]) + # @defaultSensitive=false + # --- + # @required + API_KEY= + # @required + OTHER= + API_URL=https://example.com + `; + const { g } = await load(schema, 'shape', { resolve: true }); + expect(g.configSchema.API_KEY.isValueUnknownInShapeLoad).toBe(true); + expect(g.configSchema.API_KEY.errors).toEqual([]); + // not matched by pick, so the bulk data cannot fill it + expect(g.configSchema.OTHER.errors.map((e) => e.message)).toEqual(['Value is required but is currently empty']); + // an item that already has a value keeps it + expect(g.configSchema.API_URL.resolvedValue).toBe('https://example.com'); + expect(g.shapeSkipped).toContainEqual({ kind: 'rootDecoratorExecute', name: 'setValuesBulk' }); + }); + + test('an unknown enabled= condition leaves the items unknown too', async () => { + const schema = outdent` + # @setValuesBulk('{"API_KEY":"abc"}', format=json, enabled=eq(exec("echo yes"), "yes")) + # --- + # @required + API_KEY= + `; + const { g } = await load(schema, 'shape', { resolve: true }); + expect(g.configSchema.API_KEY.isValueUnknownInShapeLoad).toBe(true); + expect(g.configSchema.API_KEY.errors.filter((e) => !e.isWarning)).toEqual([]); + }); + }); + describe.skipIf(process.platform === 'win32')('non-regular files', () => { const allErrors = (g: EnvGraph) => g.sortedDataSources.flatMap((s) => s.errors.map((e) => e.message)).join('\n'); let dir: string; @@ -500,6 +609,20 @@ describe('EnvGraph shape mode', () => { expect(allErrors(g)).toMatch(/Not a regular file: .*child.*\.env\.schema/); }, 5000); + test.each(['default', 'shape'] as const)('a plugin whose package.json is a pipe (%s mode)', async (mode) => { + fs.mkdirSync(path.join(dir, 'my-plugin')); + execFileSync('mkfifo', [path.join(dir, 'my-plugin', 'package.json')]); + const schemaPath = path.join(dir, '.env.schema'); + fs.writeFileSync(schemaPath, '# @plugin(./my-plugin)\n# ---\nA=1\n'); + const g = new EnvGraph({ mode }); + await g.setRootDataSource(new DotEnvFileDataSource(schemaPath)); + await g.finishLoad(); + const pluginErrors = g.sortedDataSources.flatMap( + (s) => s.rootDecorators.flatMap((d) => d.schemaErrors.map((e) => e.message)), + ); + expect(pluginErrors.join('\n')).toMatch(/package\.json is not a regular file/); + }, 5000); + test('the root schema itself', async () => { const schemaPath = path.join(dir, '.env.schema'); execFileSync('mkfifo', [schemaPath]); From 4b5d38cb128fd2b2810f3093e3ca8db93f2a1b1f Mon Sep 17 00:00:00 2001 From: voiys Date: Tue, 10 Nov 2026 11:42:45 +0100 Subject: [PATCH 07/12] Shape mode: a mode option on loadEnvGraph, so a tool can shape-load a project folder In shape mode the loader does not clear or open a cache store. --- packages/varlock/src/env-graph/lib/loader.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/varlock/src/env-graph/lib/loader.ts b/packages/varlock/src/env-graph/lib/loader.ts index 9afcf274f..183e2eec2 100644 --- a/packages/varlock/src/env-graph/lib/loader.ts +++ b/packages/varlock/src/env-graph/lib/loader.ts @@ -26,8 +26,14 @@ export async function loadEnvGraph(opts?: { clearCache?: boolean, skipCache?: boolean, afterInit?: (graph: EnvGraph) => Promise, + /** + * `shape` loads the schema for tools that only inspect it (see `EnvGraph.mode`): nothing reaches outside + * the process, so no cache is cleared or opened here either + */ + mode?: 'default' | 'shape', }) { - const graph = new EnvGraph(); + const graph = new EnvGraph({ mode: opts?.mode }); + const isShapeLoad = graph.mode === 'shape'; if (opts?.overrideValues) graph.overrideValues = opts.overrideValues; if (opts?.processEnvOverride) graph.processEnvOverride = opts.processEnvOverride; @@ -39,7 +45,7 @@ export async function loadEnvGraph(opts?: { // --clear-cache always clears the persistent disk cache(s), even when combined // with --skip-cache or when the active store for this run is memory-backed - if (opts?.clearCache) { + if (opts?.clearCache && !isShapeLoad) { const diskStores = [new CacheStore()]; if (envKey) { try { @@ -58,7 +64,7 @@ export async function loadEnvGraph(opts?: { // initialize cache store (encryption key is ensured lazily on first write) // auto policy: native-backend disk > env-key disk > in-process memory - if (!opts?.skipCache) { + if (!opts?.skipCache && !isShapeLoad) { // getBackendType() is a filesystem-only check. It must not spawn the native // helper: that runs on every load, and on WSL2 a single spawn of the Windows // .exe through interop measured ~5s in #1078 (0.7s load -> 5.9s) From 61f5544ec7260318231d47e8ede3cb29723259b1 Mon Sep 17 00:00:00 2001 From: voiys Date: Sat, 24 Oct 2026 09:23:28 +0200 Subject: [PATCH 08/12] Shape mode: report requiredness, sensitivity and forEnv() that depend on a skipped value as unknown A computed @required/@optional or @sensitive/@public built from a skipped resolver is now unknown instead of whatever the empty value happened to give: the item is treated as optional and sensitive, and isRequiredUnknownInShapeLoad / isSensitiveUnknownInShapeLoad say so. forEnv() with an unknown @currentEnv is unknown instead of an error, so a @disable or @required built on it no longer fails the load. Checks that only hold for a sensitive item are skipped while sensitivity is unknown. Default mode and generated types are unchanged. --- .bumpy/env-graph-shape-mode.md | 2 +- .../src/content/docs/guides/shape-mode.mdx | 4 +- .../varlock/src/env-graph/lib/config-item.ts | 41 ++++- .../varlock/src/env-graph/lib/env-graph.ts | 3 +- .../varlock/src/env-graph/lib/resolver.ts | 5 + .../src/env-graph/test/shape-mode.test.ts | 164 ++++++++++++++++++ 6 files changed, 212 insertions(+), 7 deletions(-) diff --git a/.bumpy/env-graph-shape-mode.md b/.bumpy/env-graph-shape-mode.md index 607550e59..8e14b8238 100644 --- a/.bumpy/env-graph-shape-mode.md +++ b/.bumpy/env-graph-shape-mode.md @@ -2,4 +2,4 @@ varlock: minor --- -Add a shape mode for tools that only inspect a schema. `new EnvGraph({ mode: "shape" })` runs only pure resolvers, skips root decorator execution and the cache, never downloads plugins, ignores the shell environment, keeps generated code inside the project, and refuses schema files that are not regular files. `@setValuesBulk` is applied when its data is written in the schema, and a file that is only loaded because its condition could not be evaluated never loads a plugin. Plugin resolvers can opt in with `pure: true`. +Add a shape mode for tools that only inspect a schema. `new EnvGraph({ mode: "shape" })` (or `loadEnvGraph({ mode: "shape" })`) runs only pure resolvers, skips root decorator execution and the cache, never downloads plugins, ignores the shell environment, keeps generated code inside the project, and refuses schema files that are not regular files. `@setValuesBulk` is applied when its data is written in the schema, and a file that is only loaded because its condition could not be evaluated never loads a plugin. Plugin resolvers can opt in with `pure: true`. A `@required`/`@sensitive` or `forEnv()` that depends on a skipped value is reported as unknown (`isRequiredUnknownInShapeLoad`, `isSensitiveUnknownInShapeLoad`) and treated as optional and sensitive. diff --git a/packages/varlock-website/src/content/docs/guides/shape-mode.mdx b/packages/varlock-website/src/content/docs/guides/shape-mode.mdx index 9ed1af4e7..6eb939a72 100644 --- a/packages/varlock-website/src/content/docs/guides/shape-mode.mdx +++ b/packages/varlock-website/src/content/docs/guides/shape-mode.mdx @@ -67,7 +67,9 @@ These built-in resolvers only read other values, so they still run: `concat()`, Everything else resolves to `undefined`: [`exec()`](/reference/functions/#exec), the random generators, [`generateOtp()`](/reference/functions/#generateotp), [`cache()`](/reference/functions/#cache), and plugin resolvers that are not [marked pure](#marking-plugin-resolvers-as-pure). An item whose value depends on one of them (directly, or through a reference like `COPY=$TOKEN`) is unknown. It is left unset and is not coerced, validated, or reported as a missing required value. A `@disable` or `@import(..., enabled=...)` condition that depends on one is treated as enabled, so the file's items stay in the graph. A normal load might skip that file entirely, so a `@plugin` in it (or in a file it imports) is not loaded: the decorator gets a warning and the plugin is recorded in `shapeSkipped` as `pluginLoad`. An error that a resolver raises because one of its own inputs is unknown (for example `domainFromUrl()` of a half-built URL) is cleared for an item's value. An error from a part of the expression that doesn't depend on a skipped resolver is still reported, so `concat(exec(...), domainFromUrl("mailto:x"))` still shows the `domainFromUrl()` error. It is not cleared inside a `@disable` or `@import` condition. -A function or decorator that depends on a skipped value, such as `if()`, `eq()`, or `@sensitive=eq(exec("..."), yes)`, sees it as empty, so what it returns can differ from default mode. If `@currentEnv` itself points at a skipped value, `forEnv()` has no environment to compare against and reports an error. +An `@required`, `@optional`, `@sensitive`, or `@public` that depends on a skipped value (for example `@sensitive=eq(exec("..."), yes)`) is unknown. The item is then treated as optional and sensitive, so it reports no missing value and hides nothing, and `item.isRequiredUnknownInShapeLoad` or `item.isSensitiveUnknownInShapeLoad` is `true` so a tool can show it as unknown. Both are always `false` in default mode. Checks that only apply to a sensitive item (a sensitive boolean, number, or short value, or a sensitive value inside a public one) are skipped for it. If `@currentEnv` points at a skipped value, `forEnv()` is unknown too: `@required=forEnv(production)` is unknown, a value built with it is unknown, and `@disable=forEnv(test)` keeps the file. No `.env.[env]` file is loaded, since the environment is not known. + +Generated types are not affected. Code generation never evaluates a computed `@required` or `@sensitive`, in either mode: such an item is optional, and sensitive unless its data type says otherwise. Other computed decorators, such as `@dynamic`, still see a skipped value as empty. ### Bulk values diff --git a/packages/varlock/src/env-graph/lib/config-item.ts b/packages/varlock/src/env-graph/lib/config-item.ts index 90303f972..dc89b4d54 100644 --- a/packages/varlock/src/env-graph/lib/config-item.ts +++ b/packages/varlock/src/env-graph/lib/config-item.ts @@ -508,8 +508,10 @@ export class ConfigItem { * because that will affect type generation (only _always_ required items are never undefined) * */ _isRequiredDynamic: boolean = false; + private _isRequiredUnknown = false; private async processRequired() { + this._isRequiredUnknown = false; try { // Pass 1: explicit per-item @required / @optional decorators take highest priority for (const def of this.defs) { @@ -532,6 +534,12 @@ export class ConfigItem { this._isRequired = false; return; } + // shape mode: built from a skipped resolver, so unknown. Optional is the side that reports no false errors + if (requiredDec.decValueResolver?.resultUnknownInShapeLoad) { + this._isRequired = false; + this._isRequiredUnknown = true; + return; + } if (![true, false, undefined].includes(requiredDecoratorVal)) { throw new SchemaError('@required/@optional must resolve to a boolean or undefined'); } @@ -575,6 +583,11 @@ export class ConfigItem { } get isRequired() { return this._isRequired; } get isRequiredDynamic() { return this._isRequiredDynamic; } + /** + * Shape mode: `@required`/`@optional` depends on a skipped resolver, so whether the item is required is + * unknown. `isRequired` is then false. Always false in default mode. + */ + get isRequiredUnknownInShapeLoad() { return this._isRequiredUnknown; } _isSensitive: boolean = true; @@ -586,6 +599,12 @@ export class ConfigItem { get isSensitive(): boolean { return this._isSensitive; } + private _isSensitiveUnknown = false; + /** + * Shape mode: `@sensitive`/`@public` depends on a skipped resolver, so whether the item is sensitive is + * unknown. `isSensitive` is then true. Always false in default mode. + */ + get isSensitiveUnknownInShapeLoad() { return this._isSensitiveUnknown; } /** how this item's sensitivity was determined — used by `varlock explain` */ get sensitiveSource(): 'explicit' | 'data-type' | 'resolver' | 'default-decorator' | 'prefix' | 'proxy' | 'default' { @@ -645,7 +664,8 @@ export class ConfigItem { * build - a silent runtime behavior change, which belongs in a breaking release. */ private checkSensitiveIsPlausible() { - if (!this._isSensitive || this.isBuiltin) return; + // an unknown sensitivity is true only to be safe, so nothing is reported about it + if (!this._isSensitive || this.isBuiltin || this._isSensitiveUnknown) return; // a specific message here replaces the generic length rules rather than stacking on top const flag = (explicit: SchemaError, implicit: SchemaError) => { this._sensitiveIsImplausible = true; @@ -714,12 +734,14 @@ export class ConfigItem { )); } this._isSensitive = true; + this._isSensitiveUnknown = false; this._sensitiveSource = 'proxy'; } } private async resolveSensitiveSource() { const sensitiveFromDataType = this.dataType?.isSensitive; + this._isSensitiveUnknown = false; // Pass 1: explicit per-item @sensitive / @public decorators take highest priority for (const def of this.defs) { @@ -739,7 +761,10 @@ export class ConfigItem { } const resolved = await sensitiveDec.resolve() as Record | undefined; if (sensitiveDec.schemaErrors.some((e) => !e.isWarning)) return; - this._isSensitive = this.applySensitiveOptions(resolved ?? {}); + // shape mode: an `enabled` built from a skipped resolver is unknown, so take the safe side (sensitive) + this._isSensitiveUnknown = !!sensitiveDec.decValueResolver?.objArgs?.enabled?.resultUnknownInShapeLoad; + const opts = this._isSensitiveUnknown ? { ...resolved, enabled: true } : resolved ?? {}; + this._isSensitive = this.applySensitiveOptions(opts); this._sensitiveExplicitlySet = true; this._sensitiveSource = 'explicit'; return; @@ -752,6 +777,14 @@ export class ConfigItem { const sensitiveDecValue = await sensitiveDec.resolve(); if (sensitiveDec.schemaErrors.some((e) => !e.isWarning)) return; + // shape mode: built from a skipped resolver, so unknown. Sensitive is the safe side + if (sensitiveDec.decValueResolver?.resultUnknownInShapeLoad) { + this._isSensitive = true; + this._isSensitiveUnknown = true; + this._sensitiveExplicitlySet = true; + this._sensitiveSource = 'explicit'; + return; + } if (![true, false, undefined].includes(sensitiveDecValue)) { this._schemaErrors.push(new SchemaError('@sensitive/@public must resolve to a boolean or undefined')); return; @@ -1259,7 +1292,7 @@ export class ConfigItem { // a composite hides unredactable leaves from the scalar type rules in // checkSensitiveIsPlausible - `@type=array(number)` is a composite type whose // elements are all numbers, and only string leaves are ever registered - const compositeIsUnredactable = this.isSensitive + const compositeIsUnredactable = this.isSensitive && !this._isSensitiveUnknown && isCompositeCoercedType(this.dataType?.coercedType) && unredactable.length > 0; if (compositeIsUnredactable) { @@ -1275,7 +1308,7 @@ export class ConfigItem { // skipped when something more specific was already said about this item, or when a // real error means it is broken anyway - const lengthRulesApply = this.isSensitive + const lengthRulesApply = this.isSensitive && !this._isSensitiveUnknown && !compositeIsUnredactable && !this._sensitiveIsImplausible && !this._schemaErrors.some((e) => !e.isWarning); diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index 25ccd229a..7fa967d57 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -1077,7 +1077,8 @@ export class EnvGraph { const sensitiveStrings: Array<{ key: string, str: string }> = []; for (const itemKey in this.configSchema) { const item = this.configSchema[itemKey]; - if (!item.isSensitive) continue; + // an unknown sensitivity (shape mode) is true only to be safe, so it does not make another value leak + if (!item.isSensitive || item.isSensitiveUnknownInShapeLoad) continue; // mirrors what resetRedactionMap registers: every string leaf, plus the serialized // form of a composite - so an array(number) with no redactable leaves still has its // joined "3000,3001" in the map diff --git a/packages/varlock/src/env-graph/lib/resolver.ts b/packages/varlock/src/env-graph/lib/resolver.ts index d57907411..0d1007cf0 100644 --- a/packages/varlock/src/env-graph/lib/resolver.ts +++ b/packages/varlock/src/env-graph/lib/resolver.ts @@ -744,6 +744,11 @@ export const ForEnvResolver: typeof Resolver = createResolver({ async resolve(matchEnvArgs) { // this will trigger resolution of the current env if not already done const currentEnv = await this.getCurrentEnv(); + // shape mode: when @currentEnv is built from a skipped resolver, whether it matches is unknown too + if (!currentEnv && this.dataSource?.envFlagConfigItem?.isValueUnknownInShapeLoad) { + this._unknownInShapeLoad = true; + return undefined; + } if (!currentEnv) throw new SchemaError('current environment is not set'); const matchEnvs: Array = []; for (const arg of matchEnvArgs) { diff --git a/packages/varlock/src/env-graph/test/shape-mode.test.ts b/packages/varlock/src/env-graph/test/shape-mode.test.ts index 5ba58b080..8a73e9bb7 100644 --- a/packages/varlock/src/env-graph/test/shape-mode.test.ts +++ b/packages/varlock/src/env-graph/test/shape-mode.test.ts @@ -632,3 +632,167 @@ describe('EnvGraph shape mode', () => { }, 5000); }); }); + +// A decorator built from a skipped value has an unknown result. Requiredness and sensitivity stay booleans (the +// safe side: optional, sensitive) and the shape-only flags say they are a guess. Default mode never sets them. +describe('EnvGraph shape mode: unknown requiredness and sensitivity', () => { + const unknownFlags = (g: EnvGraph, key: string) => { + const item = g.configSchema[key]; + return { + required: item.isRequired, + requiredUnknown: item.isRequiredUnknownInShapeLoad, + sensitive: item.isSensitive, + sensitiveUnknown: item.isSensitiveUnknownInShapeLoad, + }; + }; + + test('@required, @sensitive and @public built from a skipped resolver are unknown', async () => { + const schema = outdent` + # @defaultSensitive=false + # --- + # @required=eq(exec("echo yes"), yes) + REQ= + # @sensitive=eq(exec("echo yes"), yes) + SENS=x + # @public=eq(exec("echo yes"), yes) + PUB=x + # @sensitive={enabled=eq(exec("echo yes"), yes)} + SENS_OBJ=x + # @sensitive={enabled=true, preventLeaks=eq(exec("echo yes"), yes)} + SENS_OBJ_KNOWN=x + # @required @public + KNOWN=x + `; + const { g } = await load(schema, 'shape', { resolve: true }); + expect(unknownFlags(g, 'REQ')).toEqual({ + required: false, requiredUnknown: true, sensitive: false, sensitiveUnknown: false, + }); + // an unknown requiredness is not reported as a missing value + expect(g.configSchema.REQ.errors).toEqual([]); + for (const key of ['SENS', 'PUB', 'SENS_OBJ']) { + expect(unknownFlags(g, key)).toEqual({ + required: true, requiredUnknown: false, sensitive: true, sensitiveUnknown: true, + }); + } + // only `enabled` decides sensitivity, an unknown option does not + expect(g.configSchema.SENS_OBJ_KNOWN.isSensitiveUnknownInShapeLoad).toBe(false); + expect(unknownFlags(g, 'KNOWN')).toEqual({ + required: true, requiredUnknown: false, sensitive: false, sensitiveUnknown: false, + }); + + // control: default mode runs exec, knows the answers, and never sets the flags + const { g: d } = await load(schema, 'default', { resolve: true }); + expect(unknownFlags(d, 'REQ')).toEqual({ + required: true, requiredUnknown: false, sensitive: false, sensitiveUnknown: false, + }); + expect(unknownFlags(d, 'PUB')).toEqual({ + required: true, requiredUnknown: false, sensitive: false, sensitiveUnknown: false, + }); + expect(d.configSchema.SENS.isSensitive).toBe(true); + }); + + test('nothing that only holds for a sensitive item is reported when sensitivity is unknown', async () => { + const schema = outdent` + # @defaultSensitive=false + # --- + # @type=boolean + # @sensitive=eq(exec("echo yes"), yes) + FLAG=true + # @sensitive=eq(exec("echo yes"), yes) + SHORT=x + # @sensitive=eq(exec("echo yes"), yes) + LONG=abcdefghijkl + PUBLIC_COPY=prefix-abcdefghijkl + `; + const { g } = await load(schema, 'shape', { resolve: true }); + expect(g.configSchema.FLAG.isSensitiveUnknownInShapeLoad).toBe(true); + expect(g.configSchema.FLAG.errors).toEqual([]); + expect(g.configSchema.SHORT.errors).toEqual([]); + expect(g.configSchema.PUBLIC_COPY.errors).toEqual([]); + + // control: default mode knows both are sensitive, and says so + const { g: d } = await load(schema, 'default', { resolve: true }); + expect(d.configSchema.FLAG.errors.map((e) => e.message)).toContain('a boolean value cannot be sensitive'); + expect(d.configSchema.SHORT.errors.map((e) => e.message).join('\n')).toMatch(/too short to redact/); + expect(d.configSchema.PUBLIC_COPY.errors.map((e) => e.message)).toContain('Value contains the sensitive value of LONG'); + }); + + test('forEnv() is unknown when @currentEnv is, in decorators, values and @disable', async () => { + const files = { + '.env.schema': outdent` + # @currentEnv=$APP_ENV @defaultSensitive=false + # @import(./.env.prod-only) + # --- + APP_ENV=exec("echo production") + # @required=forEnv(production) + PROD_ONLY= + # @sensitive=forEnv(production) + MAYBE_SECRET=x + LABEL=if(forEnv(production), prod, dev) + `, + '.env.prod-only': outdent` + # @disable=not(forEnv(production)) @defaultSensitive=false + # --- + IMPORTED=1 + `, + }; + const g = await loadGraph({ files, mode: 'shape' }); + await g.resolveEnvValues(); + const allErrors = [ + ...g.sortedDataSources.flatMap((s) => s.errors), + ...Object.values(g.configSchema).flatMap((i) => i.errors), + ].map((e) => e.message); + expect(allErrors).toEqual([]); + expect(g.configSchema.PROD_ONLY.isRequiredUnknownInShapeLoad).toBe(true); + expect(g.configSchema.MAYBE_SECRET.isSensitiveUnknownInShapeLoad).toBe(true); + expect(g.configSchema.LABEL.isValueUnknownInShapeLoad).toBe(true); + expect(g.configSchema.IMPORTED).toBeDefined(); + + // control: default mode knows the env + const d = await loadGraph({ files, mode: 'default' }); + await d.resolveEnvValues(); + expect(d.configSchema.PROD_ONLY.isRequired).toBe(true); + expect(d.configSchema.LABEL.resolvedValue).toBe('prod'); + expect(d.configSchema.IMPORTED).toBeDefined(); + }); + + test('a known @currentEnv still evaluates forEnv() exactly', async () => { + const { g } = await load(outdent` + # @currentEnv=$APP_ENV + # --- + APP_ENV=development + # @required=forEnv(production) + PROD_ONLY= + `, 'shape', { resolve: true }); + expect(unknownFlags(g, 'PROD_ONLY')).toMatchObject({ required: false, requiredUnknown: false }); + }); + + test('generated types already take the safe side and match default mode', async () => { + // getTypeGenInfo() never resolves a computed @required or @sensitive (and root defaults must be static), + // so unknown metadata cannot reach generated types: optional and sensitive in both modes + const files = { + '.env.schema': outdent` + # @currentEnv=$APP_ENV @defaultSensitive=false + # --- + APP_ENV=exec("echo production") + # @required=forEnv(production) + # @sensitive=eq(exec("echo yes"), yes) + TOKEN=abc + # @public=eq(exec("echo yes"), yes) + LABEL=abc + `, + }; + const fields = async (mode: 'default' | 'shape') => { + const g = await loadGraph({ files, mode }); + return resolveFieldTypes(await collectTypeGenItems(g)) + .filter((f) => f.key !== 'APP_ENV') + .map((f) => ({ key: f.key, isRequired: f.isRequired, isSensitive: f.isSensitive })); + }; + const shapeFields = await fields('shape'); + expect(shapeFields).toEqual([ + { key: 'TOKEN', isRequired: false, isSensitive: true }, + { key: 'LABEL', isRequired: true, isSensitive: true }, + ]); + expect(shapeFields).toEqual(await fields('default')); + }); +}); From 119795f3dc20a50f079d5e60266df58223eb0f08 Mon Sep 17 00:00:00 2001 From: voiys Date: Sat, 24 Oct 2026 10:16:01 +0200 Subject: [PATCH 09/12] Unknown metadata: one sensitivity predicate, taint unknown env in getCurrentEnv --- .../varlock/src/env-graph/lib/config-item.ts | 41 ++++++++++++------- .../varlock/src/env-graph/lib/env-graph.ts | 2 +- .../varlock/src/env-graph/lib/resolver.ts | 15 ++++--- 3 files changed, 37 insertions(+), 21 deletions(-) diff --git a/packages/varlock/src/env-graph/lib/config-item.ts b/packages/varlock/src/env-graph/lib/config-item.ts index dc89b4d54..2128158f8 100644 --- a/packages/varlock/src/env-graph/lib/config-item.ts +++ b/packages/varlock/src/env-graph/lib/config-item.ts @@ -535,7 +535,7 @@ export class ConfigItem { return; } // shape mode: built from a skipped resolver, so unknown. Optional is the side that reports no false errors - if (requiredDec.decValueResolver?.resultUnknownInShapeLoad) { + if (this.resolverUnknownInShapeLoad(requiredDec.decValueResolver)) { this._isRequired = false; this._isRequiredUnknown = true; return; @@ -605,6 +605,19 @@ export class ConfigItem { * unknown. `isSensitive` is then true. Always false in default mode. */ get isSensitiveUnknownInShapeLoad() { return this._isSensitiveUnknown; } + /** sensitive because the schema says so, not as a fail-closed guess about an unknown sensitivity */ + get isKnownSensitive() { return this._isSensitive && !this._isSensitiveUnknown; } + /** shape mode: whether a resolver's last result depends on a skipped one. Always false in default mode */ + private resolverUnknownInShapeLoad(resolver?: Resolver) { + return this.envGraph.mode === 'shape' && !!resolver?.resultUnknownInShapeLoad; + } + /** shape mode: `@sensitive`/`@public` depends on a skipped resolver, so take the safe side (sensitive) */ + private setSensitivityUnknown() { + this._isSensitive = true; + this._isSensitiveUnknown = true; + this._sensitiveExplicitlySet = true; + this._sensitiveSource = 'explicit'; + } /** how this item's sensitivity was determined — used by `varlock explain` */ get sensitiveSource(): 'explicit' | 'data-type' | 'resolver' | 'default-decorator' | 'prefix' | 'proxy' | 'default' { @@ -665,7 +678,7 @@ export class ConfigItem { */ private checkSensitiveIsPlausible() { // an unknown sensitivity is true only to be safe, so nothing is reported about it - if (!this._isSensitive || this.isBuiltin || this._isSensitiveUnknown) return; + if (!this.isKnownSensitive || this.isBuiltin) return; // a specific message here replaces the generic length rules rather than stacking on top const flag = (explicit: SchemaError, implicit: SchemaError) => { this._sensitiveIsImplausible = true; @@ -761,10 +774,13 @@ export class ConfigItem { } const resolved = await sensitiveDec.resolve() as Record | undefined; if (sensitiveDec.schemaErrors.some((e) => !e.isWarning)) return; - // shape mode: an `enabled` built from a skipped resolver is unknown, so take the safe side (sensitive) - this._isSensitiveUnknown = !!sensitiveDec.decValueResolver?.objArgs?.enabled?.resultUnknownInShapeLoad; - const opts = this._isSensitiveUnknown ? { ...resolved, enabled: true } : resolved ?? {}; - this._isSensitive = this.applySensitiveOptions(opts); + // shape mode: an `enabled` built from a skipped resolver is unknown. The other options still apply + if (this.resolverUnknownInShapeLoad(sensitiveDec.decValueResolver?.objArgs?.enabled)) { + this.applySensitiveOptions({ ...resolved, enabled: true }); + this.setSensitivityUnknown(); + return; + } + this._isSensitive = this.applySensitiveOptions(resolved ?? {}); this._sensitiveExplicitlySet = true; this._sensitiveSource = 'explicit'; return; @@ -777,12 +793,9 @@ export class ConfigItem { const sensitiveDecValue = await sensitiveDec.resolve(); if (sensitiveDec.schemaErrors.some((e) => !e.isWarning)) return; - // shape mode: built from a skipped resolver, so unknown. Sensitive is the safe side - if (sensitiveDec.decValueResolver?.resultUnknownInShapeLoad) { - this._isSensitive = true; - this._isSensitiveUnknown = true; - this._sensitiveExplicitlySet = true; - this._sensitiveSource = 'explicit'; + // shape mode: built from a skipped resolver, so unknown + if (this.resolverUnknownInShapeLoad(sensitiveDec.decValueResolver)) { + this.setSensitivityUnknown(); return; } if (![true, false, undefined].includes(sensitiveDecValue)) { @@ -1292,7 +1305,7 @@ export class ConfigItem { // a composite hides unredactable leaves from the scalar type rules in // checkSensitiveIsPlausible - `@type=array(number)` is a composite type whose // elements are all numbers, and only string leaves are ever registered - const compositeIsUnredactable = this.isSensitive && !this._isSensitiveUnknown + const compositeIsUnredactable = this.isKnownSensitive && isCompositeCoercedType(this.dataType?.coercedType) && unredactable.length > 0; if (compositeIsUnredactable) { @@ -1308,7 +1321,7 @@ export class ConfigItem { // skipped when something more specific was already said about this item, or when a // real error means it is broken anyway - const lengthRulesApply = this.isSensitive && !this._isSensitiveUnknown + const lengthRulesApply = this.isKnownSensitive && !compositeIsUnredactable && !this._sensitiveIsImplausible && !this._schemaErrors.some((e) => !e.isWarning); diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index 7fa967d57..11fd27af0 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -1078,7 +1078,7 @@ export class EnvGraph { for (const itemKey in this.configSchema) { const item = this.configSchema[itemKey]; // an unknown sensitivity (shape mode) is true only to be safe, so it does not make another value leak - if (!item.isSensitive || item.isSensitiveUnknownInShapeLoad) continue; + if (!item.isKnownSensitive) continue; // mirrors what resetRedactionMap registers: every string leaf, plus the serialized // form of a composite - so an array(number) with no redactable leaves still has its // joined "3000,3001" in the map diff --git a/packages/varlock/src/env-graph/lib/resolver.ts b/packages/varlock/src/env-graph/lib/resolver.ts index 0d1007cf0..26b9f1055 100644 --- a/packages/varlock/src/env-graph/lib/resolver.ts +++ b/packages/varlock/src/env-graph/lib/resolver.ts @@ -259,7 +259,11 @@ export class Resolver { protected async getCurrentEnv() { if (!this.dataSource) throw new Error('expected dataSource to be set'); await this.dataSource.resolveCurrentEnv(); - return this.dataSource.envFlagValue ? String(this.dataSource.envFlagValue) : undefined; + const currentEnv = this.dataSource.envFlagValue ? String(this.dataSource.envFlagValue) : undefined; + // shape mode: the @currentEnv item is built from a skipped resolver, so the current env is unknown. + // This reads the item directly rather than through getDepValue, so it marks the taint itself + if (!currentEnv && this.dataSource.envFlagConfigItem?.isValueUnknownInShapeLoad) this._unknownInShapeLoad = true; + return currentEnv; } async resolve() { @@ -744,12 +748,11 @@ export const ForEnvResolver: typeof Resolver = createResolver({ async resolve(matchEnvArgs) { // this will trigger resolution of the current env if not already done const currentEnv = await this.getCurrentEnv(); - // shape mode: when @currentEnv is built from a skipped resolver, whether it matches is unknown too - if (!currentEnv && this.dataSource?.envFlagConfigItem?.isValueUnknownInShapeLoad) { - this._unknownInShapeLoad = true; - return undefined; + if (!currentEnv) { + // shape mode: getCurrentEnv marked the current env unknown, so whether it matches is unknown too + if (this._unknownInShapeLoad) return undefined; + throw new SchemaError('current environment is not set'); } - if (!currentEnv) throw new SchemaError('current environment is not set'); const matchEnvs: Array = []; for (const arg of matchEnvArgs) { const argValue = await arg.resolve(); From 70f6d096e827f9dabe58fdc4add78895f682468c Mon Sep 17 00:00:00 2001 From: voiys Date: Tue, 10 Nov 2026 01:10:50 +0100 Subject: [PATCH 10/12] Check for dependency cycles even when another item has an error finishLoad returned before the cycle check when any item had a schema error. An item on a cycle was then left unmarked, waited on itself when values were resolved, and the CLI gave up with "varlock stopped before the command finished" instead of showing either error. --- .bumpy/cycle-with-schema-error.md | 5 +++++ .../varlock/src/env-graph/lib/env-graph.ts | 7 ++++--- .../test/cycle-with-schema-error.test.ts | 21 +++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 .bumpy/cycle-with-schema-error.md create mode 100644 packages/varlock/src/env-graph/test/cycle-with-schema-error.test.ts diff --git a/.bumpy/cycle-with-schema-error.md b/.bumpy/cycle-with-schema-error.md new file mode 100644 index 000000000..45a15fbbc --- /dev/null +++ b/.bumpy/cycle-with-schema-error.md @@ -0,0 +1,5 @@ +--- +"varlock": patch +--- + +A dependency cycle is now reported even when another item has a schema error, instead of the load stopping with "varlock stopped before the command finished" diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index 11fd27af0..c783ac1ba 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -793,9 +793,8 @@ export class EnvGraph { } this.configItemsProcessed = true; - if (hasErrors) return; - - // check for cycles in resolver dependencies + // check for cycles in resolver dependencies. This runs even when another item already has an error: + // an item on an unmarked cycle waits on itself forever when values are resolved const cycles = findGraphCycles(this.graphAdjacencyList); for (const cycleItemKeys of cycles) { for (const itemKey of cycleItemKeys) { @@ -809,6 +808,8 @@ export class EnvGraph { ); } } + + if (hasErrors) return; this.checkBootDynamicItems(); // now execute all root decorators diff --git a/packages/varlock/src/env-graph/test/cycle-with-schema-error.test.ts b/packages/varlock/src/env-graph/test/cycle-with-schema-error.test.ts new file mode 100644 index 000000000..8d4fbfdca --- /dev/null +++ b/packages/varlock/src/env-graph/test/cycle-with-schema-error.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import outdent from 'outdent'; +import { EnvGraph } from '../index'; +import { DotEnvFileDataSource } from '../lib/data-source'; + +describe('a dependency cycle next to an unrelated schema error', () => { + it('is still reported, and resolving finishes', async () => { + const g = new EnvGraph(); + await g.setRootDataSource(new DotEnvFileDataSource('.env.schema', { + overrideContents: outdent` + B=$B + C=$Z + `, + })); + await g.finishLoad(); + // without the cycle error on B, this promise never settles + await g.resolveEnvValues(); + expect(g.configSchema.B.errors.map((e) => e.message)).toContain('Item cannot have dependency on itself'); + expect(g.configSchema.C.errors.map((e) => e.message)).toContain('ref(): invalid dependency: Z'); + }, 5000); +}); From 637e3a1eacfe1cb402a331104f80232e624e571a Mon Sep 17 00:00:00 2001 From: voiys Date: Tue, 10 Nov 2026 12:07:20 +0100 Subject: [PATCH 11/12] Shape mode: unknown whenever validity depends on something skipped, with a generated soundness test A shape load now reports an item as unknown, instead of as valid, when one of its dependencies is unknown (a normal load fails an item whenever a dependency is invalid, read or not), when it is empty and its requiredness is unknown, or when its sensitivity is unknown and a sensitive-only check would fail. shape-soundness.test.ts generates 1,200 schemas and checks, for every item, that a shape load says unknown or gives exactly the value and errors of a normal load, and the same required and sensitive unless flagged unknown. It fails on every seed without these rules. --- .../src/content/docs/guides/shape-mode.mdx | 10 ++ .../varlock/src/env-graph/lib/config-item.ts | 65 ++++++++- .../varlock/src/env-graph/lib/env-graph.ts | 7 + .../env-graph/test/shape-soundness.test.ts | 130 ++++++++++++++++++ 4 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 packages/varlock/src/env-graph/test/shape-soundness.test.ts diff --git a/packages/varlock-website/src/content/docs/guides/shape-mode.mdx b/packages/varlock-website/src/content/docs/guides/shape-mode.mdx index 6eb939a72..963c2e89c 100644 --- a/packages/varlock-website/src/content/docs/guides/shape-mode.mdx +++ b/packages/varlock-website/src/content/docs/guides/shape-mode.mdx @@ -69,6 +69,16 @@ Everything else resolves to `undefined`: [`exec()`](/reference/functions/#exec), An `@required`, `@optional`, `@sensitive`, or `@public` that depends on a skipped value (for example `@sensitive=eq(exec("..."), yes)`) is unknown. The item is then treated as optional and sensitive, so it reports no missing value and hides nothing, and `item.isRequiredUnknownInShapeLoad` or `item.isSensitiveUnknownInShapeLoad` is `true` so a tool can show it as unknown. Both are always `false` in default mode. Checks that only apply to a sensitive item (a sensitive boolean, number, or short value, or a sensitive value inside a public one) are skipped for it. If `@currentEnv` points at a skipped value, `forEnv()` is unknown too: `@required=forEnv(production)` is unknown, a value built with it is unknown, and `@disable=forEnv(test)` keeps the file. No `.env.[env]` file is loaded, since the environment is not known. +### What unknown guarantees + +For every item, a shape load either reports the value as unknown (`item.isValueUnknownInShapeLoad`) or reports the same value and the same errors as a normal load. The same holds for `isRequired` and `isSensitive`, unless the matching `...UnknownInShapeLoad` flag is set. To keep that promise, an item is also unknown when: + +- one of its dependencies is unknown, even one in a branch that isn't taken. A normal load fails an item whenever a dependency is invalid, and an unknown dependency may turn out invalid. +- it is empty and whether it is required is unknown. +- whether it is sensitive is unknown, and it would fail a check that only applies to sensitive items (for example a one-character value, or a boolean). + +Two limits. A skipped resolver is assumed to succeed with some value: if it fails in a normal load, every item that depends on it fails there too. And an item that is invalid for certain may be missing further errors that depend on something skipped. + Generated types are not affected. Code generation never evaluates a computed `@required` or `@sensitive`, in either mode: such an item is optional, and sensitive unless its data type says otherwise. Other computed decorators, such as `@dynamic`, still see a skipped value as empty. ### Bulk values diff --git a/packages/varlock/src/env-graph/lib/config-item.ts b/packages/varlock/src/env-graph/lib/config-item.ts index 2128158f8..4f5e1ce45 100644 --- a/packages/varlock/src/env-graph/lib/config-item.ts +++ b/packages/varlock/src/env-graph/lib/config-item.ts @@ -606,7 +606,38 @@ export class ConfigItem { */ get isSensitiveUnknownInShapeLoad() { return this._isSensitiveUnknown; } /** sensitive because the schema says so, not as a fail-closed guess about an unknown sensitivity */ - get isKnownSensitive() { return this._isSensitive && !this._isSensitiveUnknown; } + get isKnownSensitive() { return this._isSensitive && (!this._isSensitiveUnknown || this._probingAsSensitive); } + /** @internal shape mode: set while the sensitive-only checks run to see what they would say if it were */ + private _probingAsSensitive = false; + /** + * @internal shape mode: whether the item is valid depends on a skipped resolver (a requiredness or + * sensitivity that is unknown, and a value that would fail if it turned out to apply). The value is then + * reported as unknown rather than as valid. + */ + private _validityUnknownInShapeLoad = false; + /** + * Shape mode, unknown sensitivity: run sensitive-only checks as if the item were sensitive, undo whatever + * they reported, and say whether one of them was a real error. + */ + private wouldFailIfSensitive(runChecks: () => void): boolean { + const schemaErrorCount = this._schemaErrors.length; + const validationErrorsBefore = this.validationErrors; + const implausibleBefore = this._sensitiveIsImplausible; + this._probingAsSensitive = true; + try { + runChecks(); + } finally { + this._probingAsSensitive = false; + } + const added = [ + ...this._schemaErrors.slice(schemaErrorCount), + ...(this.validationErrors ?? []).filter((e) => !validationErrorsBefore?.includes(e)), + ]; + this._schemaErrors.length = schemaErrorCount; + this.validationErrors = validationErrorsBefore; + this._sensitiveIsImplausible = implausibleBefore; + return added.some((e) => !e.isWarning); + } /** shape mode: whether a resolver's last result depends on a skipped one. Always false in default mode */ private resolverUnknownInShapeLoad(resolver?: Resolver) { return this.envGraph.mode === 'shape' && !!resolver?.resultUnknownInShapeLoad; @@ -733,6 +764,9 @@ export class ConfigItem { // validation still runs), then force sensitivity for @proxy-managed items below. await this.resolveSensitiveSource(); this.checkSensitiveIsPlausible(); + if (this._isSensitiveUnknown && this.wouldFailIfSensitive(() => this.checkSensitiveIsPlausible())) { + this._validityUnknownInShapeLoad = true; + } // @proxy-managed items are always sensitive: the proxied child only ever sees a // placeholder while the real value is injected at the wire, so force sensitivity @@ -1176,6 +1210,12 @@ export class ConfigItem { // shape mode: a resolver that failed on a partial value (e.g. `domainFromUrl()` of a half-built url) // did not really fail, the value is just unknown. Errors from fully known parts of the value are kept if (this.resolutionError && errorFromUnknownInput) this.resolutionError = undefined; + // shape mode: an item is invalid whenever one of its dependencies is, read or not, and an unknown + // dependency may turn out invalid (an empty result on a required item, a value its type rejects). So the + // item is unknown too, whatever its own resolver said + const hasUnknownDependency = this.envGraph.mode === 'shape' + && this.dependencyKeys.some((key) => this.envGraph.configSchema[key]?.isValueUnknownInShapeLoad); + if (hasUnknownDependency) this.resolutionError = undefined; if (this.resolvedRawValue instanceof RegExp) { this.resolutionError = new ResolutionError('Regex values are meant to be used within function args (e.g., remap()), not as a final resolved value'); @@ -1187,7 +1227,11 @@ export class ConfigItem { this.isResolved = true; // shape mode: bulk data we could not read might fill this item, so empty means unknown here too - if (this._maybeSetByUnknownBulkInShapeLoad && (this.resolvedRawValue === undefined || this.resolvedRawValue === '')) { + const isEmpty = this.resolvedRawValue === undefined || this.resolvedRawValue === ''; + if (this._maybeSetByUnknownBulkInShapeLoad && isEmpty) this._valueUnknownInShapeLoad = true; + // required or not is unknown, and it matters because there is no value + const requiredCheckUnknown = this._isRequiredUnknown && isEmpty; + if (hasUnknownDependency || requiredCheckUnknown || this._validityUnknownInShapeLoad) { this._valueUnknownInShapeLoad = true; } @@ -1295,6 +1339,11 @@ export class ConfigItem { } this.isValidated = true; + // shape mode, unknown sensitivity: the checks below run as if the item were sensitive. If one of them + // is a real error the value is reported as unknown; whatever they said is dropped either way + const errorsBeforeSensitiveChecks = this.validationErrors; + this._probingAsSensitive = this._isSensitiveUnknown; + // Redaction can only protect what it can match: a string, long enough not to // collide with ordinary text. What follows reports where that does not hold. const addError = (err: ValidationError) => { @@ -1351,7 +1400,19 @@ export class ConfigItem { }, )); } + + if (this._probingAsSensitive) { + this._probingAsSensitive = false; + const added = (this.validationErrors ?? []).filter((e) => !errorsBeforeSensitiveChecks?.includes(e)); + this.validationErrors = errorsBeforeSensitiveChecks; + if (added.some((e) => !e.isWarning) && !this.validationErrors?.some((e) => !e.isWarning)) { + this._valueUnknownInShapeLoad = true; + this.resolvedRawValue = undefined; + this.resolvedValue = undefined; + } + } } catch (err) { + this._probingAsSensitive = false; if (_.isArray(err)) { // could do more checking... this.validationErrors = err as Array; diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index c783ac1ba..b1b1e41d9 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -999,16 +999,23 @@ export class EnvGraph { return; } + // shape mode: a dependency whose value is unknown may turn out invalid in a normal load. Once one comes + // first, a later dependency that is certainly invalid no longer decides the outcome (a normal load would + // name whichever is first), and the item resolves to unknown instead (see ConfigItem.resolve) + let unknownDependencyFirst = false; for (const depKey of adjList[itemKey] || []) { const depItem = this.configSchema[depKey]; // if a dependency is invalid, we mark the item as invalid too if (depItem.validationState === 'error') { + if (unknownDependencyFirst) continue; item.resolutionError = new ResolutionError(`Dependency ${depKey} is invalid`); markItemCompleted(itemKey); return; // if any dependency is not yet resolved, we need to wait for it } else if (depKey in itemsToResolveStatus) { return; + } else if (depItem.isValueUnknownInShapeLoad) { + unknownDependencyFirst = true; } } diff --git a/packages/varlock/src/env-graph/test/shape-soundness.test.ts b/packages/varlock/src/env-graph/test/shape-soundness.test.ts new file mode 100644 index 000000000..46cea7dbf --- /dev/null +++ b/packages/varlock/src/env-graph/test/shape-soundness.test.ts @@ -0,0 +1,130 @@ +import { describe, test, expect } from 'vitest'; +import { loadGraph } from './type-generation/helpers'; + +// What shape mode promises, checked on generated schemas: for every item, a shape load either says the value +// is unknown, or gives exactly what a normal load gives (the value and its errors). The same goes for +// required and sensitive, unless shape mode flags that one as unknown. +// +// Two things a normal load does are not held against it: +// - a skipped resolver is assumed to succeed with some value. One that fails makes every item that depends +// on it invalid, which no static answer can match, so the generated commands never fail. +// - an item that is invalid for certain may be missing further errors that depend on something skipped, but +// every error a shape load reports has to be one the normal load reports too. + +/** small seeded generator (mulberry32), so a failure can be replayed from its seed */ +/* eslint-disable no-bitwise -- the generator is bit arithmetic */ +function rng(seed: number) { + let a = seed >>> 0; + const next = () => { + a = (a + 0x6D2B79F5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + return { + chance: (p: number) => next() < p, + pick: (xs: ReadonlyArray): T => xs[Math.floor(next() * xs.length)], + int: (n: number) => Math.floor(next() * n), + }; +} +/* eslint-enable no-bitwise */ +type Rng = ReturnType; + +const KEYS = ['A', 'B', 'C', 'D', 'E']; +const LITERALS = ['"a"', '"b"', '""', 'true', 'false', '"true"', 'undefined']; +// each prints something and exits 0 in sh and in cmd.exe +const COMMANDS = ['echo a', 'echo b', 'echo true']; + +function genExpr(r: Rng, depth: number): string { + const leaf = () => { + if (r.chance(0.2)) return `exec("${r.pick(COMMANDS)}")`; + return r.chance(0.5) ? `$${r.pick(KEYS)}` : r.pick(LITERALS); + }; + if (depth === 0 || r.chance(0.35)) return leaf(); + const sub = () => genExpr(r, depth - 1); + switch (r.int(4)) { + case 0: return `concat(${sub()}, ${sub()})`; + case 1: return `fallback(${sub()}, ${sub()})`; + case 2: return `eq(${sub()}, ${sub()})`; + default: return `if(eq(${sub()}, ${sub()}), ${sub()}, ${sub()})`; + } +} + +function genFiles(r: Rng): Record { + const header = '# @defaultRequired=false @defaultSensitive=false\n# ---\n'; + const files: Record = {}; + const names = ['.env.schema']; + if (r.chance(0.4)) names.push('.env'); + if (r.chance(0.3)) names.push('.env.local'); + for (const name of names) { + const lines: Array = []; + for (const key of KEYS) { + if (!r.chance(name === '.env.schema' ? 0.75 : 0.35)) continue; + if (name === '.env.schema' && r.chance(0.6)) { + const cond = () => (r.chance(0.5) ? `exec("${r.pick(COMMANDS)}")` : `$${r.pick(KEYS)}`); + const decorators: Array = []; + if (r.chance(0.5)) decorators.push(`@required=eq(${cond()}, ${r.pick(['a', 'b', '""', 'true'])})`); + if (r.chance(0.5)) decorators.push(`@sensitive=eq(${cond()}, ${r.pick(['a', 'true'])})`); + if (r.chance(0.3)) { + decorators.push(`@type=${r.pick(['boolean', 'string(minLength=1)', 'enum(a, b, true)', 'number'])}`); + } + if (decorators.length) lines.push(`# ${decorators.join(' ')}`); + } + lines.push(r.chance(0.1) ? `${key}=` : `${key}=${genExpr(r, 2)}`); + } + files[name] = `${header}${lines.join('\n')}\n`; + } + return files; +} + +async function summarize(files: Record, mode: 'default' | 'shape') { + const g = await loadGraph({ files, mode, overrideValues: {} }); + await g.resolveEnvValues(); + return Object.fromEntries(g.sortedConfigKeys.map((key) => { + const item = g.configSchema[key]; + return [ + key, { + value: item.resolvedValue, + errors: item.errors.filter((e) => !e.isWarning).map((e) => e.message).sort(), + required: item.isRequired, + sensitive: item.isSensitive, + valueUnknown: item.isValueUnknownInShapeLoad, + requiredUnknown: item.isRequiredUnknownInShapeLoad, + sensitiveUnknown: item.isSensitiveUnknownInShapeLoad, + }, + ]; + })); +} + +describe('shape mode says unknown, or the same as a normal load', () => { + test.each([1, 2, 3, 4, 5, 6])('200 generated schemas, seed %i', async (seed) => { + const r = rng(seed); + let known = 0; + for (let i = 0; i < 200; i++) { + const files = genFiles(r); + const normal = await summarize(files, 'default'); + const shape = await summarize(files, 'shape'); + const where = `seed ${seed}, schema ${i}:\n${Object.entries(files).map(([name, text]) => `[${name}]\n${text}`).join('')}`; + expect(Object.keys(shape).sort(), where).toEqual(Object.keys(normal).sort()); + for (const [key, n] of Object.entries(normal)) { + const s = shape[key]; + if (!s.valueUnknown) { + known++; + expect(s.value, `${key} value, ${where}`).toEqual(n.value); + if (s.errors.length && n.errors.length) { + expect(n.errors, `${key} errors, ${where}`).toEqual(expect.arrayContaining(s.errors)); + } else { + expect(s.errors, `${key} errors, ${where}`).toEqual(n.errors); + } + } + // a normal load leaves required and sensitive at their initial values on an item that failed + if (n.errors.length) continue; + if (!s.requiredUnknown) expect(s.required, `${key} required, ${where}`).toBe(n.required); + if (!s.sensitiveUnknown) expect(s.sensitive, `${key} sensitive, ${where}`).toBe(n.sensitive); + } + } + // the check means nothing if shape mode calls everything unknown + expect(known).toBeGreaterThan(300); + }, 60_000); +}); From c3c443d3b473ee88347361dcf201d1598eb13952 Mon Sep 17 00:00:00 2001 From: voiys Date: Tue, 10 Nov 2026 12:13:00 +0100 Subject: [PATCH 12/12] Shape mode: a piped env file is skipped, not an error; quieter skipped list; classify every built-in resolver A named pipe or device is skipped and recorded (kind nonRegularFile) instead of failing the load: 1Password mounts a .env that way. It could set any item, so every value is then unknown. shapeSkipped no longer lists root decorators that have no execute(). A test lists every built-in resolver as pure or skipped, so a new one fails until someone decides. Also: resolution-context is imported once, and errors from unknown inputs are tracked in a WeakSet instead of a property written onto the error. --- .bumpy/env-graph-shape-mode.md | 2 +- .../src/content/docs/guides/shape-mode.mdx | 6 +- .../varlock/src/env-graph/lib/config-item.ts | 7 +- .../varlock/src/env-graph/lib/data-source.ts | 7 +- .../varlock/src/env-graph/lib/decorators.ts | 4 + .../varlock/src/env-graph/lib/env-graph.ts | 9 ++- .../varlock/src/env-graph/lib/resolver.ts | 20 +++-- .../src/env-graph/test/shape-mode.test.ts | 79 ++++++++++++++++++- 8 files changed, 113 insertions(+), 21 deletions(-) diff --git a/.bumpy/env-graph-shape-mode.md b/.bumpy/env-graph-shape-mode.md index 8e14b8238..d369c3729 100644 --- a/.bumpy/env-graph-shape-mode.md +++ b/.bumpy/env-graph-shape-mode.md @@ -2,4 +2,4 @@ varlock: minor --- -Add a shape mode for tools that only inspect a schema. `new EnvGraph({ mode: "shape" })` (or `loadEnvGraph({ mode: "shape" })`) runs only pure resolvers, skips root decorator execution and the cache, never downloads plugins, ignores the shell environment, keeps generated code inside the project, and refuses schema files that are not regular files. `@setValuesBulk` is applied when its data is written in the schema, and a file that is only loaded because its condition could not be evaluated never loads a plugin. Plugin resolvers can opt in with `pure: true`. A `@required`/`@sensitive` or `forEnv()` that depends on a skipped value is reported as unknown (`isRequiredUnknownInShapeLoad`, `isSensitiveUnknownInShapeLoad`) and treated as optional and sensitive. +Add a shape mode for tools that only inspect a schema. `new EnvGraph({ mode: "shape" })` (or `loadEnvGraph({ mode: "shape" })`) runs only pure resolvers, skips root decorator execution and the cache, never downloads plugins, ignores the shell environment, keeps generated code inside the project, and skips files that are not regular files. `@setValuesBulk` is applied when its data is written in the schema, and a file that is only loaded because its condition could not be evaluated never loads a plugin. Plugin resolvers can opt in with `pure: true`. A `@required`/`@sensitive` or `forEnv()` that depends on a skipped value is reported as unknown (`isRequiredUnknownInShapeLoad`, `isSensitiveUnknownInShapeLoad`) and treated as optional and sensitive. diff --git a/packages/varlock-website/src/content/docs/guides/shape-mode.mdx b/packages/varlock-website/src/content/docs/guides/shape-mode.mdx index 963c2e89c..de513b78a 100644 --- a/packages/varlock-website/src/content/docs/guides/shape-mode.mdx +++ b/packages/varlock-website/src/content/docs/guides/shape-mode.mdx @@ -39,7 +39,7 @@ for (const key of graph.sortedConfigKeys) { console.log(key, info.dataType?.coercedType ?? 'string', info.isRequired); } -// everything that was not run, e.g. [{ kind: 'rootDecoratorExecute', name: 'currentEnv' }] +// everything that was not run, e.g. [{ kind: 'resolver', name: 'exec', itemKey: 'TOKEN' }] console.log(graph.shapeSkipped); ``` @@ -55,9 +55,9 @@ This loads the schema file and the files it imports. Value files such as `.env.l | Plugins | a pinned plugin is downloaded if missing | never downloaded. If it isn't installed or cached, loading reports a schema error | | `process.env` | overrides item values, and builtin variables read it | ignored. Values come from the files that were loaded, and builtin variables only see an env you pass in explicitly (`graph.processEnvOverride`) | | Code generation | writes wherever `path` points | only inside the directory of the root schema or directory; a path that leaves it (`..`, absolute, or through a symlink) is a schema error | -| Files | read as usual | a schema or env file that is not a regular file (a named pipe or a device), whether it is the root file, imported directly, or found in an imported directory, is a loading error instead of a read that never returns | +| Files | read as usual | a schema or env file that is not a regular file (a named pipe or a device) is skipped and recorded, wherever it is found, because reading one can block forever. Some tools deliver values this way (1Password mounts a `.env` as a pipe), so it is not an error. A skipped file could set any item, so every item's value is then unknown | -Everything skipped is recorded in `graph.shapeSkipped` as `{ kind, name, itemKey? }`, where `kind` is `resolver`, `rootDecoratorExecute`, `cacheStore`, `pluginDownload`, `pluginLoad`, or `codegenWrite`, and `itemKey` is the item whose value was being resolved when a resolver was skipped. Tools can use it to tell users which values could not be computed. +Everything skipped is recorded in `graph.shapeSkipped` as `{ kind, name, itemKey? }`, where `kind` is `resolver`, `rootDecoratorExecute`, `cacheStore`, `pluginDownload`, `pluginLoad`, `codegenWrite`, or `nonRegularFile`, and `itemKey` is the item whose value was being resolved when a resolver was skipped. Tools can use it to tell users which values could not be computed. Apart from those differences, a tool sees the same types that [code generation](/guides/code-generation/) produces, and the same requiredness, sensitivity, and validation messages, except where a decorator or function depends on a value that was skipped (see [Pure resolvers](#pure-resolvers)). [Builtin variables](/reference/builtin-variables/) such as `VARLOCK_ENV` are computed from an env you pass in explicitly, or from an empty one, so the same schema gives the same result on any machine. Without CI variables in that env, `VARLOCK_BRANCH` is unknown (and listed in `shapeSkipped`), because the local `git branch --show-current` lookup is skipped. diff --git a/packages/varlock/src/env-graph/lib/config-item.ts b/packages/varlock/src/env-graph/lib/config-item.ts index 4f5e1ce45..d7570f4fd 100644 --- a/packages/varlock/src/env-graph/lib/config-item.ts +++ b/packages/varlock/src/env-graph/lib/config-item.ts @@ -19,7 +19,7 @@ import type { CacheHitInfo } from './resolution-context'; import { EnvGraphDataSource } from './data-source'; import { convertParsedValueToResolvers, getMalformedFunctionCallError, type ResolvedValue, Resolver, StaticValueResolver, - ArrayLiteralResolver, ObjectLiteralResolver, + ArrayLiteralResolver, ObjectLiteralResolver, errorsFromUnknownInput, } from './resolver'; import { buildTypeSpecPlan, coercedTypesMatch, type TypeSpecPlan } from './type-decorator'; import { ItemDecoratorInstance } from './decorators'; @@ -1196,7 +1196,7 @@ export class ConfigItem { try { this.resolvedRawValue = await this.valueResolver.resolve(); } catch (err) { - errorFromUnknownInput = !!(err as any)?._unknownInputInShapeLoad; + errorFromUnknownInput = !!err && typeof err === 'object' && errorsFromUnknownInput.has(err); if (err instanceof ResolutionError) { this.resolutionError = err; } else { @@ -1229,6 +1229,9 @@ export class ConfigItem { // shape mode: bulk data we could not read might fill this item, so empty means unknown here too const isEmpty = this.resolvedRawValue === undefined || this.resolvedRawValue === ''; if (this._maybeSetByUnknownBulkInShapeLoad && isEmpty) this._valueUnknownInShapeLoad = true; + // shape mode: a source that was not read (a named pipe, see FileBasedDataSource) could set or override + // any item, so no value is known + if (this.envGraph._hasUnreadSourceInShapeLoad) this._valueUnknownInShapeLoad = true; // required or not is unknown, and it matters because there is no value const requiredCheckUnknown = this._isRequiredUnknown && isEmpty; if (hasUnknownDependency || requiredCheckUnknown || this._validityUnknownInShapeLoad) { diff --git a/packages/varlock/src/env-graph/lib/data-source.ts b/packages/varlock/src/env-graph/lib/data-source.ts index 9c9d7899b..223158667 100644 --- a/packages/varlock/src/env-graph/lib/data-source.ts +++ b/packages/varlock/src/env-graph/lib/data-source.ts @@ -969,9 +969,12 @@ export abstract class FileBasedDataSource extends EnvGraphDataSource { return; } // shape mode: a named pipe or device would block the read forever, hanging every editor keystroke or - // CI lint. Checked here so it covers direct imports, directory imports and auto-loaded files alike + // CI lint. It is also how some tools deliver values (1Password mounts a `.env` as a pipe), so this is + // not an error: the file is skipped and recorded, and since it could set any item, every value is + // unknown. Checked here so it covers direct imports, directory imports and auto-loaded files alike if (this.graph?.mode === 'shape' && !fileStat.isFile()) { - this._errors.push(new LoadingError(`Not a regular file: ${this.fullPath}`)); + this.graph.shapeSkipped.push({ kind: 'nonRegularFile', name: this.fullPath }); + this.graph._hasUnreadSourceInShapeLoad = true; return; } this.rawContents = await fs.readFile(this.fullPath, 'utf8'); diff --git a/packages/varlock/src/env-graph/lib/decorators.ts b/packages/varlock/src/env-graph/lib/decorators.ts index 1a3753d36..1a0acc938 100644 --- a/packages/varlock/src/env-graph/lib/decorators.ts +++ b/packages/varlock/src/env-graph/lib/decorators.ts @@ -71,6 +71,10 @@ export abstract class DecoratorInstance { get isInert() { return !!this.decoratorDef?.inert; } + /** @internal whether this decorator does anything when executed (most only carry a value) */ + get hasExecute(): boolean { + return !!(this.decoratorDef as RootDecoratorDef | undefined)?.execute; + } /** @internal whether an EnvGraph in `shape` mode may call execute(), see RootDecoratorDef.runsInShapeLoad */ get runsInShapeLoad(): boolean { return !!(this.decoratorDef as RootDecoratorDef | undefined)?.runsInShapeLoad; diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index b1b1e41d9..38b5d0f10 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -239,12 +239,16 @@ export class EnvGraph { */ readonly mode: 'default' | 'shape' = 'default'; shapeSkipped: Array<{ - kind: 'resolver' | 'rootDecoratorExecute' | 'cacheStore' | 'pluginDownload' | 'pluginLoad' | 'codegenWrite', + kind: 'resolver' | 'rootDecoratorExecute' | 'cacheStore' | 'pluginDownload' | 'pluginLoad' | 'codegenWrite' + | 'nonRegularFile', name: string, /** the item being resolved when a resolver was skipped, when known */ itemKey?: string, }> = []; + /** @internal shape mode: a file was skipped instead of read, so no item's value can be known */ + _hasUnreadSourceInShapeLoad = false; + /** root data source (.env.schema) */ rootDataSource?: EnvGraphDataSource; @@ -825,7 +829,8 @@ export class EnvGraph { // shape mode still resolves the decorator's deps above (only pure resolvers run), which also // re-validates them with their final metadata; it only skips the side-effecting execute() if (this.mode === 'shape' && !decInstance.runsInShapeLoad) { - this.shapeSkipped.push({ kind: 'rootDecoratorExecute', name: decInstance.name }); + // most root decorators only carry a value and have nothing to skip + if (decInstance.hasExecute) this.shapeSkipped.push({ kind: 'rootDecoratorExecute', name: decInstance.name }); continue; } try { diff --git a/packages/varlock/src/env-graph/lib/resolver.ts b/packages/varlock/src/env-graph/lib/resolver.ts index 26b9f1055..3efbaa160 100644 --- a/packages/varlock/src/env-graph/lib/resolver.ts +++ b/packages/varlock/src/env-graph/lib/resolver.ts @@ -25,6 +25,16 @@ import { DecoratorInstance } from './decorators'; import { getErrorLocation } from './error-location'; import { isBuiltinVar } from './builtin-vars'; import { REGEX_LIKE_STRING, parseRegexLikeString } from './regex-like-string'; +import { getResolutionContext } from './resolution-context'; + +/** + * @internal shape mode: errors a resolver threw while one of its inputs was unknown. Such an error is about a + * partial value (e.g. `domainFromUrl()` of a half-built url), not a real failure. Noted where the error is + * first thrown, so an error from a part of the expression that was fully known stays real. + */ +export const errorsFromUnknownInput = new WeakSet(); +/** errors already seen by a resolver, so only the innermost one decides the above */ +const errorsSeenInShapeLoad = new WeakSet(); const execAsync = promisify(exec); @@ -269,7 +279,6 @@ export class Resolver { async resolve() { const graph = this.envGraph ?? this.dataSource?.graph; if (graph?.mode === 'shape' && !this.def.pure) { - const { getResolutionContext } = await import('./resolution-context'); const item = getResolutionContext()?.currentItem; graph.shapeSkipped.push({ kind: 'resolver', name: this.def.name, ...item && { itemKey: item.key } }); this._unknownInShapeLoad = true; @@ -279,11 +288,9 @@ export class Resolver { const resolvedValue = await this.def.resolve.call(this, this.meta); return resolvedValue; } catch (err) { - // shape mode: note, where the error was first thrown, whether this resolver's inputs were unknown. Only - // then is it an error about a partial value (e.g. `domainFromUrl()` of a half-built url); an error from a - // part of the expression that was fully known is real - if (graph?.mode === 'shape' && err && typeof err === 'object' && !('_unknownInputInShapeLoad' in err)) { - (err as any)._unknownInputInShapeLoad = this.resultUnknownInShapeLoad; + if (graph?.mode === 'shape' && err && typeof err === 'object' && !errorsSeenInShapeLoad.has(err)) { + errorsSeenInShapeLoad.add(err); + if (this.resultUnknownInShapeLoad) errorsFromUnknownInput.add(err); } if (err instanceof VarlockError) { // prefix error message with resolver function name (matching schema error behavior) @@ -1350,7 +1357,6 @@ export const CacheResolver: typeof Resolver = createResolver({ return { ttl, customKey }; }, async resolve(state) { - const { getResolutionContext } = await import('./resolution-context'); const ctx = getResolutionContext(); const cacheStore = ctx?.cacheStore; const item = ctx?.currentItem; diff --git a/packages/varlock/src/env-graph/test/shape-mode.test.ts b/packages/varlock/src/env-graph/test/shape-mode.test.ts index 8a73e9bb7..ab7ecf842 100644 --- a/packages/varlock/src/env-graph/test/shape-mode.test.ts +++ b/packages/varlock/src/env-graph/test/shape-mode.test.ts @@ -7,7 +7,7 @@ import os from 'node:os'; import path from 'node:path'; import outdent from 'outdent'; import { - EnvGraph, DotEnvFileDataSource, collectTypeGenItems, generateTsTypesSrc, resolveFieldTypes, + EnvGraph, DotEnvFileDataSource, collectTypeGenItems, generateTsTypesSrc, loadEnvGraph, resolveFieldTypes, } from '../index'; import { loadGraph } from './type-generation/helpers'; @@ -579,6 +579,57 @@ describe('EnvGraph shape mode', () => { }); }); + test('shapeSkipped only lists root decorators that had something to run', async () => { + const { g } = await load(outdent` + # @currentEnv=$APP_ENV + # @defaultSensitive=false + # @defaultRequired=false + # --- + APP_ENV=dev + `, 'shape', { resolve: true }); + expect(g.shapeSkipped).toEqual([]); + }); + + test('every built-in resolver is classified as pure or skipped', () => { + // A new built-in resolver fails here until someone decides. Leaving it out of both lists is the safe + // default (shape mode skips it), but then values built from it quietly turn unknown in every tool. + const pure = [ + 'concat', + 'fallback', + 'ref', + 'regex', + 'remap', + 'forEnv', + 'eq', + 'if', + 'ifs', + 'not', + 'and', + 'or', + 'isEmpty', + 'domainFromUrl', + 'inferFromPrefix', + ]; + const g = new EnvGraph({ mode: 'shape' }); + const classified = Object.fromEntries( + Object.entries(g.registeredResolverFunctions).map(([name, resolverClass]) => [name, !!resolverClass.def.pure]), + ); + const actualPure = Object.keys(classified).filter((name) => classified[name]).sort(); + expect(actualPure).toEqual([...pure].sort()); + const skipped = Object.keys(classified).filter((name) => !classified[name]).sort(); + expect(skipped).toMatchInlineSnapshot(` + [ + "cache", + "exec", + "generateOtp", + "randomHex", + "randomNum", + "randomString", + "randomUuid", + ] + `); + }); + describe.skipIf(process.platform === 'win32')('non-regular files', () => { const allErrors = (g: EnvGraph) => g.sortedDataSources.flatMap((s) => s.errors.map((e) => e.message)).join('\n'); let dir: string; @@ -595,7 +646,9 @@ describe('EnvGraph shape mode', () => { fs.writeFileSync(schemaPath, '# @import(./.env.pipe)\n# ---\nA=1\n'); const g = new EnvGraph({ mode: 'shape' }); await g.setRootDataSource(new DotEnvFileDataSource(schemaPath)); - expect(allErrors(g)).toMatch(/Not a regular file: .*\.env\.pipe/); + await g.finishLoad(); + expect(allErrors(g)).toBe(''); + expect(g.shapeSkipped).toContainEqual({ kind: 'nonRegularFile', name: path.join(dir, '.env.pipe') }); // no default-mode control: there the read blocks forever }, 5000); @@ -606,7 +659,8 @@ describe('EnvGraph shape mode', () => { fs.writeFileSync(schemaPath, '# @import(./child/)\n# ---\nA=1\n'); const g = new EnvGraph({ mode: 'shape' }); await g.setRootDataSource(new DotEnvFileDataSource(schemaPath)); - expect(allErrors(g)).toMatch(/Not a regular file: .*child.*\.env\.schema/); + expect(allErrors(g)).toBe(''); + expect(g.shapeSkipped).toContainEqual({ kind: 'nonRegularFile', name: path.join(dir, 'child', '.env.schema') }); }, 5000); test.each(['default', 'shape'] as const)('a plugin whose package.json is a pipe (%s mode)', async (mode) => { @@ -628,7 +682,24 @@ describe('EnvGraph shape mode', () => { execFileSync('mkfifo', [schemaPath]); const g = new EnvGraph({ mode: 'shape' }); await g.setRootDataSource(new DotEnvFileDataSource(schemaPath)); - expect(allErrors(g)).toMatch(/Not a regular file/); + expect(allErrors(g)).toBe(''); + expect(g.shapeSkipped).toContainEqual({ kind: 'nonRegularFile', name: schemaPath }); + }, 5000); + + test('a .env delivered as a pipe is skipped, and every value is unknown because it could set any of them', async () => { + fs.writeFileSync(path.join(dir, '.env.schema'), '# @defaultSensitive=false\n# ---\n# @type=port\nPORT=3000\n# @required\nAPI_KEY=\n'); + execFileSync('mkfifo', [path.join(dir, '.env')]); + const g = await loadEnvGraph({ entryFilePaths: `${dir}/`, mode: 'shape' }); + await g.resolveEnvValues(); + expect(allErrors(g)).toBe(''); + expect(g.shapeSkipped).toContainEqual({ kind: 'nonRegularFile', name: path.join(dir, '.env') }); + // the schema is still fully known + expect(g.configSchema.PORT.dataType?.name).toBe('port'); + expect(g.configSchema.API_KEY.isRequired).toBe(true); + // the values are not: the pipe could override PORT and fill API_KEY + expect(g.configSchema.PORT.isValueUnknownInShapeLoad).toBe(true); + expect(g.configSchema.API_KEY.isValueUnknownInShapeLoad).toBe(true); + expect(g.configSchema.API_KEY.errors).toEqual([]); }, 5000); }); });