From 12b571e749d0db7573e1c7aec17383d2172e9ed9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 11:45:41 +0000 Subject: [PATCH 1/7] Initial plan From 15bde361dc45f613fba47dd99e4a1d677b259ce6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 12:17:40 +0000 Subject: [PATCH 2/7] Fix diamond dependency double-import causing 'already initialized' error When the same schema directory is imported via multiple paths (diamond dependency), the schema file was being loaded twice. This caused plugin init decorators (like @initAws) to run twice, throwing "Instance with id '_default' already initialized". Fix: Add _loadedImportPaths Set to EnvGraph to track already-loaded import paths. Before adding a new DirectoryDataSource or DotEnvFileDataSource for an import, check if the path was already imported. If so, skip the duplicate import. Also adds a test plugin (test-plugin-with-init) and three new tests in import.test.ts covering the diamond dependency pattern for directory imports, directory imports with plugins, and file imports. Agent-Logs-Url: https://github.com/dmno-dev/varlock/sessions/a36fdb26-ef5a-41af-a057-cfa6d4d04f7c Co-authored-by: philmillman <3722211+philmillman@users.noreply.github.com> --- .../fix-diamond-dependency-double-import.md | 5 ++ .../varlock/src/env-graph/lib/data-source.ts | 12 ++++ .../varlock/src/env-graph/lib/env-graph.ts | 7 ++ .../varlock/src/env-graph/test/import.test.ts | 72 +++++++++++++++++++ .../test-plugin-with-init/package.json | 10 +++ .../plugins/test-plugin-with-init/plugin.js | 20 ++++++ 6 files changed, 126 insertions(+) create mode 100644 .changeset/fix-diamond-dependency-double-import.md create mode 100644 packages/varlock/src/env-graph/test/plugins/test-plugin-with-init/package.json create mode 100644 packages/varlock/src/env-graph/test/plugins/test-plugin-with-init/plugin.js diff --git a/.changeset/fix-diamond-dependency-double-import.md b/.changeset/fix-diamond-dependency-double-import.md new file mode 100644 index 00000000..ff034765 --- /dev/null +++ b/.changeset/fix-diamond-dependency-double-import.md @@ -0,0 +1,5 @@ +--- +"varlock": patch +--- + +Fix "Instance with id '_default' already initialized" error when the same schema directory is imported via multiple paths (diamond dependency pattern). Previously, if schema A imported schema C and schema B imported schema A and C, loading schema A would cause C's plugin init decorators to run twice. The fix deduplicates imports by path, so each directory or file is only loaded once per graph. diff --git a/packages/varlock/src/env-graph/lib/data-source.ts b/packages/varlock/src/env-graph/lib/data-source.ts index efe7c705..2469677f 100644 --- a/packages/varlock/src/env-graph/lib/data-source.ts +++ b/packages/varlock/src/env-graph/lib/data-source.ts @@ -392,6 +392,9 @@ export abstract class EnvGraphDataSource { this._loadingError = new Error(`Virtual directory import ${fullImportPath} not found`); return; } + // Skip if already loaded (diamond dependency deduplication) + if (this.graph._loadedImportPaths.has(fullImportPath)) continue; + this.graph._loadedImportPaths.add(fullImportPath); // eslint-disable-next-line no-use-before-define await this.addChild(new DirectoryDataSource(fullImportPath), { isImport: true, importKeys, isConditionallyEnabled, @@ -403,6 +406,9 @@ export abstract class EnvGraphDataSource { this._loadingError = new Error(`Virtual import ${fullImportPath} not found`); return; } + // Skip if already loaded (diamond dependency deduplication) + if (this.graph._loadedImportPaths.has(fullImportPath)) continue; + this.graph._loadedImportPaths.add(fullImportPath); // eslint-disable-next-line no-use-before-define const source = new DotEnvFileDataSource(fullImportPath, { overrideContents: this.graph.virtualImports[fullImportPath], @@ -423,6 +429,9 @@ export abstract class EnvGraphDataSource { // directory import -- must end with a "/" to make the intent clearer if (importPath.endsWith('/')) { if (fsStat.isDirectory()) { + // Skip if already loaded (diamond dependency deduplication) + if (this.graph._loadedImportPaths.has(fullImportPath)) continue; + this.graph._loadedImportPaths.add(fullImportPath); // eslint-disable-next-line no-use-before-define await this.addChild(new DirectoryDataSource(fullImportPath), { isImport: true, importKeys, isConditionallyEnabled, @@ -440,6 +449,9 @@ export abstract class EnvGraphDataSource { this._loadingError = new Error('imported file must be a .env.* file'); return; } + // Skip if already loaded (diamond dependency deduplication) + if (this.graph._loadedImportPaths.has(fullImportPath)) continue; + this.graph._loadedImportPaths.add(fullImportPath); // TODO: once we have more file types, here we would detect the type and import it correctly // eslint-disable-next-line no-use-before-define await this.addChild(new DotEnvFileDataSource(fullImportPath), { diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index 1c09d609..93113d76 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -68,6 +68,13 @@ export class EnvGraph { configSchema: Record = {}; + /** + * Tracks directory/file paths that have already been loaded as imports. + * Used to prevent diamond-dependency re-imports (same schema imported via multiple paths), + * which would otherwise cause plugin init decorators to run multiple times. + */ + _loadedImportPaths = new Set(); + /** virtual imports for testing */ virtualImports?: Record; setVirtualImports(basePath: string, files: Record) { diff --git a/packages/varlock/src/env-graph/test/import.test.ts b/packages/varlock/src/env-graph/test/import.test.ts index 65e2ad89..6c74cfc5 100644 --- a/packages/varlock/src/env-graph/test/import.test.ts +++ b/packages/varlock/src/env-graph/test/import.test.ts @@ -446,4 +446,76 @@ describe('@import', () => { loadingError: true, })); }); + + describe('diamond dependency (same schema imported via multiple paths)', () => { + test('directory imported twice via different paths does not error', envFilesTest({ + files: { + '.env.schema': outdent` + # @import(./root/, ROOT_VAR) + # @import(./shared/) + # --- + `, + 'root/.env.schema': outdent` + ROOT_VAR=root-value + OTHER_VAR=other-value + `, + 'shared/.env.schema': outdent` + # @import(../root/, ROOT_VAR) + # --- + SHARED_VAR=shared-value + `, + }, + expectValues: { + ROOT_VAR: 'root-value', + SHARED_VAR: 'shared-value', + }, + })); + + test('directory with plugin @init imported twice via different paths does not error', envFilesTest({ + files: { + '.env.schema': outdent` + # @import(./root/, ROOT_VAR) + # @import(./shared/) + # --- + `, + 'root/.env.schema': outdent` + # @plugin(../plugins/test-plugin-with-init/) + # @initTestPlugin() + # --- + ROOT_VAR=root-value + `, + 'shared/.env.schema': outdent` + # @import(../root/, ROOT_VAR) + # --- + SHARED_VAR=shared-value + `, + }, + expectValues: { + ROOT_VAR: 'root-value', + SHARED_VAR: 'shared-value', + }, + })); + + test('file imported twice via different paths does not error', envFilesTest({ + files: { + '.env.schema': outdent` + # @import(./.env.common) + # @import(./.env.layer) + # --- + `, + '.env.common': outdent` + COMMON_VAR=common-value + `, + '.env.layer': outdent` + # @import(./.env.common) + # --- + LAYER_VAR=layer-value + `, + }, + expectValues: { + COMMON_VAR: 'common-value', + LAYER_VAR: 'layer-value', + }, + })); + }); }); diff --git a/packages/varlock/src/env-graph/test/plugins/test-plugin-with-init/package.json b/packages/varlock/src/env-graph/test/plugins/test-plugin-with-init/package.json new file mode 100644 index 00000000..ee13a5cc --- /dev/null +++ b/packages/varlock/src/env-graph/test/plugins/test-plugin-with-init/package.json @@ -0,0 +1,10 @@ +{ + "name": "@varlock/test-plugin-with-init", + "private": true, + "description": "plugin used in automated tests - has init decorator", + "version": "1.0.0", + "type": "module", + "exports": { + "./plugin": "./plugin.js" + } +} diff --git a/packages/varlock/src/env-graph/test/plugins/test-plugin-with-init/plugin.js b/packages/varlock/src/env-graph/test/plugins/test-plugin-with-init/plugin.js new file mode 100644 index 00000000..70fa36eb --- /dev/null +++ b/packages/varlock/src/env-graph/test/plugins/test-plugin-with-init/plugin.js @@ -0,0 +1,20 @@ +const { plugin, SchemaError } = require('varlock/plugin-lib'); + +// Module-level state to detect duplicate initialization (same as real plugins like 1password, aws-secrets) +const instances = {}; + +plugin.name = 'test-plugin-with-init'; +plugin.registerRootDecorator({ + name: 'initTestPlugin', + isFunction: true, + useFnArgsResolver: true, + process() { + const id = '_default'; + if (instances[id]) { + throw new SchemaError(`Instance with id "${id}" already initialized`); + } + instances[id] = true; + return { id }; + }, + execute() {}, +}); From 8a88cb202aaee4b0ab6b5c8476f900e4c76926d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 12:20:13 +0000 Subject: [PATCH 3/7] Refactor: extract checkAndRecordImportPath helper to reduce duplication Agent-Logs-Url: https://github.com/dmno-dev/varlock/sessions/a36fdb26-ef5a-41af-a057-cfa6d4d04f7c Co-authored-by: philmillman <3722211+philmillman@users.noreply.github.com> --- packages/varlock/src/env-graph/lib/data-source.ts | 12 ++++-------- packages/varlock/src/env-graph/lib/env-graph.ts | 13 ++++++++++++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/varlock/src/env-graph/lib/data-source.ts b/packages/varlock/src/env-graph/lib/data-source.ts index 2469677f..59bf65eb 100644 --- a/packages/varlock/src/env-graph/lib/data-source.ts +++ b/packages/varlock/src/env-graph/lib/data-source.ts @@ -393,8 +393,7 @@ export abstract class EnvGraphDataSource { return; } // Skip if already loaded (diamond dependency deduplication) - if (this.graph._loadedImportPaths.has(fullImportPath)) continue; - this.graph._loadedImportPaths.add(fullImportPath); + if (this.graph.checkAndRecordImportPath(fullImportPath)) continue; // eslint-disable-next-line no-use-before-define await this.addChild(new DirectoryDataSource(fullImportPath), { isImport: true, importKeys, isConditionallyEnabled, @@ -407,8 +406,7 @@ export abstract class EnvGraphDataSource { return; } // Skip if already loaded (diamond dependency deduplication) - if (this.graph._loadedImportPaths.has(fullImportPath)) continue; - this.graph._loadedImportPaths.add(fullImportPath); + if (this.graph.checkAndRecordImportPath(fullImportPath)) continue; // eslint-disable-next-line no-use-before-define const source = new DotEnvFileDataSource(fullImportPath, { overrideContents: this.graph.virtualImports[fullImportPath], @@ -430,8 +428,7 @@ export abstract class EnvGraphDataSource { if (importPath.endsWith('/')) { if (fsStat.isDirectory()) { // Skip if already loaded (diamond dependency deduplication) - if (this.graph._loadedImportPaths.has(fullImportPath)) continue; - this.graph._loadedImportPaths.add(fullImportPath); + if (this.graph.checkAndRecordImportPath(fullImportPath)) continue; // eslint-disable-next-line no-use-before-define await this.addChild(new DirectoryDataSource(fullImportPath), { isImport: true, importKeys, isConditionallyEnabled, @@ -450,8 +447,7 @@ export abstract class EnvGraphDataSource { return; } // Skip if already loaded (diamond dependency deduplication) - if (this.graph._loadedImportPaths.has(fullImportPath)) continue; - this.graph._loadedImportPaths.add(fullImportPath); + if (this.graph.checkAndRecordImportPath(fullImportPath)) continue; // TODO: once we have more file types, here we would detect the type and import it correctly // eslint-disable-next-line no-use-before-define await this.addChild(new DotEnvFileDataSource(fullImportPath), { diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index 93113d76..59f87cd1 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -73,7 +73,18 @@ export class EnvGraph { * Used to prevent diamond-dependency re-imports (same schema imported via multiple paths), * which would otherwise cause plugin init decorators to run multiple times. */ - _loadedImportPaths = new Set(); + private _loadedImportPaths = new Set(); + + /** + * Checks whether an import path has already been loaded, and records it if not. + * Returns true if the path was already loaded (caller should skip the import), + * false if it's new (caller should proceed with loading). + */ + checkAndRecordImportPath(importPath: string): boolean { + if (this._loadedImportPaths.has(importPath)) return true; + this._loadedImportPaths.add(importPath); + return false; + } /** virtual imports for testing */ virtualImports?: Record; From 4394bb757d1619c48d2d31b40577f28a36cab032 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Mon, 6 Apr 2026 16:56:52 -0700 Subject: [PATCH 4/7] Fix diamond dependency: use ImportAliasSource for correct precedence and importKeys handling The previous approach skipped duplicate imports entirely, which lost importKeys from the second import and placed definitions at the wrong precedence position. Now, duplicate imports create lightweight ImportAliasSource nodes in the tree. These alias nodes share the original source's definitions but carry their own importMeta/parent, giving them the correct position in the precedence chain. A new sortedDefinitionSources list (derived from sortedDataSources by expanding aliases) is used by ConfigItem.defs for item resolution, while sortedDataSources remains unchanged for plugin init, error collection, and serialization. Also adds type field to serialized source entries and updates the Next.js integration to filter by type instead of label prefix. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fix-diamond-dependency-double-import.md | 3 +- .../nextjs/src/next-env-compat.ts | 4 +- .../varlock/src/env-graph/lib/config-item.ts | 9 +- .../varlock/src/env-graph/lib/data-source.ts | 63 +++- .../varlock/src/env-graph/lib/env-graph.ts | 97 +++++- .../varlock/src/env-graph/test/import.test.ts | 297 ++++++++++++++++++ 6 files changed, 442 insertions(+), 31 deletions(-) diff --git a/.changeset/fix-diamond-dependency-double-import.md b/.changeset/fix-diamond-dependency-double-import.md index ff034765..68ca9b14 100644 --- a/.changeset/fix-diamond-dependency-double-import.md +++ b/.changeset/fix-diamond-dependency-double-import.md @@ -1,5 +1,6 @@ --- "varlock": patch +"@varlock/nextjs-integration": patch --- -Fix "Instance with id '_default' already initialized" error when the same schema directory is imported via multiple paths (diamond dependency pattern). Previously, if schema A imported schema C and schema B imported schema A and C, loading schema A would cause C's plugin init decorators to run twice. The fix deduplicates imports by path, so each directory or file is only loaded once per graph. +Fix diamond dependency handling when the same schema is imported via multiple paths. Previously, duplicate imports caused plugin init decorators to run twice ("Instance already initialized" error). Now, duplicate imports create lightweight `ImportAliasSource` nodes that appear at the correct precedence position without re-initializing the source. This correctly handles different importKeys subsets across import sites and preserves override semantics matching non-deduplicated behavior. Also adds `type` field to serialized source entries for easier filtering. diff --git a/packages/integrations/nextjs/src/next-env-compat.ts b/packages/integrations/nextjs/src/next-env-compat.ts index 78234840..aa911f73 100644 --- a/packages/integrations/nextjs/src/next-env-compat.ts +++ b/packages/integrations/nextjs/src/next-env-compat.ts @@ -35,9 +35,7 @@ let rootDir: string | undefined; // a list of filenames loaded, for example: `Environments: .env, .env.development` function getVarlockSourcesAsLoadedEnvFiles(): LoadedEnvFiles { const envFilesLabels = varlockLoadedEnv.sources - // TODO expose more info so we can filter out disabled sources - // and maybe show relative paths - .filter((s) => s.enabled && !s.label.startsWith('directory -')) + .filter((s) => s.enabled && s.type !== 'container' && s.type !== 'import-alias') .map((s) => s.label); if (envFilesLabels.length) { // this adds an additional line, below the list of files diff --git a/packages/varlock/src/env-graph/lib/config-item.ts b/packages/varlock/src/env-graph/lib/config-item.ts index ce4d6402..9919be3c 100644 --- a/packages/varlock/src/env-graph/lib/config-item.ts +++ b/packages/varlock/src/env-graph/lib/config-item.ts @@ -45,7 +45,7 @@ export class ConfigItem { } /** - * fetch ordered list of definitions for this item, by following up sorted data sources list + * fetch ordered list of definitions for this item, by following the sorted definition sources list * internal defs (builtins) are appended last (lowest priority) */ get defs() { @@ -53,12 +53,11 @@ export class ConfigItem { // we may want to cache the definition list at some point when loading is complete // although we need it to be dynamic during the loading process when doing any early resolution of the envFlag const defs: Array = []; - for (const source of this.envGraph.sortedDataSources) { + for (const { source, importKeys } of this.envGraph.sortedDefinitionSources) { if (!source.configItemDefs[this.key]) continue; if (source.disabled) continue; - if (source.importKeys && !source.importKeys.includes(this.key)) continue; - const itemDef = source.configItemDefs[this.key]; - if (itemDef) defs.push({ itemDef, source }); + if (importKeys && !importKeys.includes(this.key)) continue; + defs.push({ itemDef: source.configItemDefs[this.key], source }); } defs.push(...this._internalDefs); return defs; diff --git a/packages/varlock/src/env-graph/lib/data-source.ts b/packages/varlock/src/env-graph/lib/data-source.ts index 59bf65eb..c2a9532e 100644 --- a/packages/varlock/src/env-graph/lib/data-source.ts +++ b/packages/varlock/src/env-graph/lib/data-source.ts @@ -384,6 +384,18 @@ export abstract class EnvGraphDataSource { const fileName = path.basename(fullImportPath); // TODO: might be nice to move this logic somewhere else + // Check for diamond dependency: if this path was already loaded, + // add an alias instead of re-loading (prevents double plugin init) + const existingSource = this.graph.getLoadedImportSource(fullImportPath); + if (existingSource) { + // eslint-disable-next-line no-use-before-define + await this.addChild(new ImportAliasSource(existingSource), { + isImport: true, importKeys, isConditionallyEnabled, + }); + this.graph.registerItemsForImport(existingSource, this, importKeys); + continue; + } + if (this.graph.virtualImports) { if (importPath.endsWith('/')) { const dirExists = Object.keys(this.graph.virtualImports).some((p) => p.startsWith(fullImportPath)); @@ -392,12 +404,12 @@ export abstract class EnvGraphDataSource { this._loadingError = new Error(`Virtual directory import ${fullImportPath} not found`); return; } - // Skip if already loaded (diamond dependency deduplication) - if (this.graph.checkAndRecordImportPath(fullImportPath)) continue; // eslint-disable-next-line no-use-before-define - await this.addChild(new DirectoryDataSource(fullImportPath), { + const dirChild = new DirectoryDataSource(fullImportPath); + await this.addChild(dirChild, { isImport: true, importKeys, isConditionallyEnabled, }); + this.graph.recordLoadedImportPath(fullImportPath, dirChild); } else { const fileExists = this.graph.virtualImports[fullImportPath]; if (!fileExists && allowMissing) continue; @@ -405,13 +417,12 @@ export abstract class EnvGraphDataSource { this._loadingError = new Error(`Virtual import ${fullImportPath} not found`); return; } - // Skip if already loaded (diamond dependency deduplication) - if (this.graph.checkAndRecordImportPath(fullImportPath)) continue; // eslint-disable-next-line no-use-before-define - const source = new DotEnvFileDataSource(fullImportPath, { + const fileChild = new DotEnvFileDataSource(fullImportPath, { overrideContents: this.graph.virtualImports[fullImportPath], }); - await this.addChild(source, { isImport: true, importKeys, isConditionallyEnabled }); + await this.addChild(fileChild, { isImport: true, importKeys, isConditionallyEnabled }); + this.graph.recordLoadedImportPath(fullImportPath, fileChild); } } else { const fsStat = await tryCatch(async () => fs.stat(fullImportPath), (_err) => { @@ -427,12 +438,12 @@ export abstract class EnvGraphDataSource { // directory import -- must end with a "/" to make the intent clearer if (importPath.endsWith('/')) { if (fsStat.isDirectory()) { - // Skip if already loaded (diamond dependency deduplication) - if (this.graph.checkAndRecordImportPath(fullImportPath)) continue; // eslint-disable-next-line no-use-before-define - await this.addChild(new DirectoryDataSource(fullImportPath), { + const dirChild = new DirectoryDataSource(fullImportPath); + await this.addChild(dirChild, { isImport: true, importKeys, isConditionallyEnabled, }); + this.graph.recordLoadedImportPath(fullImportPath, dirChild); } else { this._loadingError = new Error(`Imported path ending with "/" is not a directory: ${fullImportPath}`); return; @@ -446,13 +457,13 @@ export abstract class EnvGraphDataSource { this._loadingError = new Error('imported file must be a .env.* file'); return; } - // Skip if already loaded (diamond dependency deduplication) - if (this.graph.checkAndRecordImportPath(fullImportPath)) continue; // TODO: once we have more file types, here we would detect the type and import it correctly // eslint-disable-next-line no-use-before-define - await this.addChild(new DotEnvFileDataSource(fullImportPath), { + const fileChild = new DotEnvFileDataSource(fullImportPath); + await this.addChild(fileChild, { isImport: true, importKeys, isConditionallyEnabled, }); + this.graph.recordLoadedImportPath(fullImportPath, fileChild); } } } else if (importPath.startsWith('http://') || importPath.startsWith('https://')) { @@ -552,6 +563,32 @@ export abstract class EnvGraphDataSource { } } +/** + * Lightweight alias created when the same path is imported from multiple locations. + * Lives in the tree like any other child (has its own importMeta/parent) but delegates + * definitions to the original source. Has no rootDecorators or configItemDefs of its own, + * so sortedDataSources consumers (plugin init, error collection, etc.) naturally skip it. + */ +export class ImportAliasSource extends EnvGraphDataSource { + type = 'import-alias' as const; + typeLabel = 'import-alias'; + + constructor( + /** the real data source this alias points to */ + readonly original: EnvGraphDataSource, + ) { + super(); + } + + get label() { return `re-import of ${this.original.label}`; } + + // no-op — the original was already fully initialized + // eslint-disable-next-line @typescript-eslint/no-empty-function + async _finishInit() {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + async _processImports() {} +} + export abstract class FileBasedDataSource extends EnvGraphDataSource { fullPath: string; fileName: string; diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index 59f87cd1..9c498fdc 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -1,7 +1,7 @@ import _ from '@env-spec/utils/my-dash'; import path from 'node:path'; import { ConfigItem } from './config-item'; -import { EnvGraphDataSource, FileBasedDataSource } from './data-source'; +import { EnvGraphDataSource, FileBasedDataSource, ImportAliasSource } from './data-source'; import { BaseResolvers, createResolver, type ResolverChildClass } from './resolver'; import { BaseDataTypes, type EnvGraphDataTypeFactory } from './data-types'; @@ -27,9 +27,18 @@ export type SerializedEnvGraphErrors = { root?: Array; }; +/** Entry in the sorted definition sources list — pairs a data source with the importKeys + * filter that applies at that specific position in the precedence chain */ +export type DefinitionSourceEntry = { + source: EnvGraphDataSource; + /** importKeys filter for this position (undefined = all keys visible) */ + importKeys?: Array; +}; + export type SerializedEnvGraph = { basePath?: string; sources: Array<{ + type: string; label: string; enabled: boolean; path?: string; @@ -70,20 +79,59 @@ export class EnvGraph { /** * Tracks directory/file paths that have already been loaded as imports. + * Maps each import path to the data source that was created for it. * Used to prevent diamond-dependency re-imports (same schema imported via multiple paths), * which would otherwise cause plugin init decorators to run multiple times. */ - private _loadedImportPaths = new Set(); + private _loadedImportPaths = new Map(); + + /** Returns the existing source for a path if already loaded, or undefined */ + getLoadedImportSource(importPath: string): EnvGraphDataSource | undefined { + return this._loadedImportPaths.get(importPath); + } + + /** Records the data source that was created for an import path */ + recordLoadedImportPath(importPath: string, dataSource: EnvGraphDataSource) { + this._loadedImportPaths.set(importPath, dataSource); + } /** - * Checks whether an import path has already been loaded, and records it if not. - * Returns true if the path was already loaded (caller should skip the import), - * false if it's new (caller should proceed with loading). + * Register ConfigItems for keys visible through an import + * that may not have been registered during the original source's finishInit. */ - checkAndRecordImportPath(importPath: string): boolean { - if (this._loadedImportPaths.has(importPath)) return true; - this._loadedImportPaths.add(importPath); - return false; + registerItemsForImport( + source: EnvGraphDataSource, + importSite: EnvGraphDataSource, + importKeys?: Array, + ) { + // Compute effective importKeys: intersection of import filter and importSite's parent chain + const siteKeys = importSite.importKeys; + let effectiveKeys: Array | undefined; + const hasFilter = importKeys && importKeys.length > 0; + if (hasFilter && siteKeys?.length) { + effectiveKeys = importKeys.filter((k) => siteKeys.includes(k)); + } else if (hasFilter) { + effectiveKeys = importKeys; + } else { + effectiveKeys = siteKeys; + } + + for (const s of this._getDescendants(source)) { + const keys = effectiveKeys || _.keys(s.configItemDefs); + for (const itemKey of keys) { + if (!s.configItemDefs[itemKey]) continue; + this.configSchema[itemKey] ??= new ConfigItem(this, itemKey); + } + } + } + + /** Get a data source and all its descendants (DFS) */ + private _getDescendants(source: EnvGraphDataSource): Array { + const result: Array = [source]; + for (const child of source.children) { + result.push(...this._getDescendants(child)); + } + return result; } /** virtual imports for testing */ @@ -103,6 +151,36 @@ export class EnvGraph { return this.rootDataSource ? getSourceAndChildren(this.rootDataSource) : []; } + /** + * Precedence-ordered list of definition sources, used by ConfigItem.defs. + * + * Unlike `sortedDataSources` (which contains each real source exactly once), + * this list can contain the same source multiple times at different positions + * when it's imported from multiple locations (diamond dependency). Each entry + * carries its own `importKeys` filter for that specific import context. + * + * Built from `sortedDataSources` by expanding `ImportAliasSource` nodes into + * the original source's full subtree at the alias's precedence position. + */ + get sortedDefinitionSources(): Array { + const result: Array = []; + + for (const source of this.sortedDataSources) { + if (source instanceof ImportAliasSource) { + // Alias: expand to the original source's subtree at this position, + // using the alias's importKeys (derived from its own parent chain) + const importKeys = source.importKeys; + for (const descendant of this._getDescendants(source.original)) { + result.push({ source: descendant, importKeys }); + } + } else { + result.push({ source, importKeys: source.importKeys }); + } + } + + return result; + } + registeredResolverFunctions: Record = {}; registerResolver(resolverClass: ResolverChildClass) { // because its a class, we can't use `name` @@ -457,6 +535,7 @@ export class EnvGraph { }; for (const source of this.sortedDataSources) { serializedGraph.sources.push({ + type: source.type, label: source.label, enabled: !source.disabled, path: source instanceof FileBasedDataSource ? path.relative(this.basePath ?? '', source.fullPath) : undefined, diff --git a/packages/varlock/src/env-graph/test/import.test.ts b/packages/varlock/src/env-graph/test/import.test.ts index 6c74cfc5..3164cf5f 100644 --- a/packages/varlock/src/env-graph/test/import.test.ts +++ b/packages/varlock/src/env-graph/test/import.test.ts @@ -517,5 +517,302 @@ describe('@import', () => { LAYER_VAR: 'layer-value', }, })); + + test('different importKeys subsets - both subsets accessible', envFilesTest({ + files: { + '.env.schema': outdent` + # @import(./.env.common, A) + # @import(./.env.layer) + # --- + `, + '.env.common': outdent` + A=val-a + B=val-b + `, + '.env.layer': outdent` + # @import(./.env.common, B) + # --- + LAYER=layer-val + `, + }, + expectValues: { + A: 'val-a', + B: 'val-b', + LAYER: 'layer-val', + }, + })); + + test('first partial, second full - all items accessible', envFilesTest({ + files: { + '.env.schema': outdent` + # @import(./.env.common, A) + # @import(./.env.layer) + # --- + `, + '.env.common': outdent` + A=val-a + B=val-b + C=val-c + `, + '.env.layer': outdent` + # @import(./.env.common) + # --- + LAYER=layer-val + `, + }, + expectValues: { + A: 'val-a', + B: 'val-b', + C: 'val-c', + LAYER: 'layer-val', + }, + })); + + test('first full, second partial - all items accessible', envFilesTest({ + files: { + '.env.schema': outdent` + # @import(./.env.common) + # @import(./.env.layer) + # --- + `, + '.env.common': outdent` + A=val-a + B=val-b + C=val-c + `, + '.env.layer': outdent` + # @import(./.env.common, B) + # --- + LAYER=layer-val + `, + }, + expectValues: { + A: 'val-a', + B: 'val-b', + C: 'val-c', + LAYER: 'layer-val', + }, + })); + + test('overlapping importKeys subsets', envFilesTest({ + files: { + '.env.schema': outdent` + # @import(./.env.common, A, B) + # @import(./.env.layer) + # --- + `, + '.env.common': outdent` + A=val-a + B=val-b + C=val-c + `, + '.env.layer': outdent` + # @import(./.env.common, B, C) + # --- + LAYER=layer-val + `, + }, + expectValues: { + A: 'val-a', + B: 'val-b', + C: 'val-c', + LAYER: 'layer-val', + }, + })); + + test('plugin @init imported twice - different importKeys still only inits plugin once', envFilesTest({ + files: { + '.env.schema': outdent` + # @import(./.env.common, ROOT_A) + # @import(./.env.layer) + # --- + `, + '.env.common': outdent` + # @plugin(./plugins/test-plugin-with-init/) + # @initTestPlugin() + # --- + ROOT_A=root-a-value + ROOT_B=root-b-value + `, + '.env.layer': outdent` + # @import(./.env.common, ROOT_B) + # --- + LAYER_VAR=layer-value + `, + }, + expectValues: { + ROOT_A: 'root-a-value', + ROOT_B: 'root-b-value', + LAYER_VAR: 'layer-value', + }, + })); + + test('items not in any importKeys subset are excluded', envFilesTest({ + files: { + '.env.schema': outdent` + # @import(./.env.common, A) + # @import(./.env.layer) + # --- + `, + '.env.common': outdent` + A=val-a + B=val-b + UNREQUESTED=should-not-appear + `, + '.env.layer': outdent` + # @import(./.env.common, B) + # --- + LAYER=layer-val + `, + }, + expectValues: { + A: 'val-a', + B: 'val-b', + LAYER: 'layer-val', + }, + expectNotInSchema: ['UNREQUESTED'], + })); + + // Precedence tests: verify that diamond deduplication doesn't break override ordering + test('second importer overrides imported item value', envFilesTest({ + // .env.layer imports .env.common (deduplicated) AND defines its own B + // layer's B should override common's B since the importer has higher priority + files: { + '.env.schema': outdent` + # @import(./.env.common, A) + # @import(./.env.layer) + # --- + `, + '.env.common': outdent` + A=common-a + B=common-b + `, + '.env.layer': outdent` + # @import(./.env.common, B) + # --- + B=layer-b + `, + }, + expectValues: { + A: 'common-a', + B: 'layer-b', + }, + })); + + test('re-import respects precedence', envFilesTest({ + files: { + '.env.schema': outdent` + # @import(./.env.import) + # @import(./.env.import2) + # --- + `, + '.env.import': outdent` + # @import(./.env.common) + # --- + A=import-a + `, + '.env.import2': outdent` + # @import(./.env.common) + `, + '.env.common': outdent` + A=common-a + `, + }, + expectValues: { + A: 'common-a', + }, + })); + + + test('main schema overrides item from deduplicated import', envFilesTest({ + // main defines A itself and also imports .env.common (which has A) + // main's definition should win since it has highest priority + files: { + '.env.schema': outdent` + # @import(./.env.common) + # @import(./.env.layer) + # --- + A=main-a + `, + '.env.common': outdent` + A=common-a + B=common-b + `, + '.env.layer': outdent` + # @import(./.env.common, B) + # --- + LAYER=layer-val + `, + }, + expectValues: { + A: 'main-a', + B: 'common-b', + LAYER: 'layer-val', + }, + })); + + test('later import of same key still gets correct value', envFilesTest({ + // Both importers request the same key A from .env.common + // common's value should be used since neither importer overrides it + files: { + '.env.schema': outdent` + # @import(./.env.layer1) + # @import(./.env.layer2) + # --- + `, + '.env.common': outdent` + A=common-a + `, + '.env.layer1': outdent` + # @import(./.env.common, A) + # --- + S1=layer1-val + `, + '.env.layer2': outdent` + # @import(./.env.common, A) + # --- + S2=layer2-val + `, + }, + expectValues: { + A: 'common-a', + S1: 'layer1-val', + S2: 'layer2-val', + }, + })); + + test('override chain: re-import at higher position promotes common over earlier override', envFilesTest({ + // overlay (higher priority) re-imports common, so common's Y appears at overlay's + // position — above base's Y=base-y override. This matches non-deduplicated behavior: + // overlay's copy of common would shadow base's definitions. + files: { + '.env.schema': outdent` + # @import(./.env.base) + # @import(./.env.overlay) + # --- + X=main-x + `, + '.env.common': outdent` + X=common-x + Y=common-y + Z=common-z + `, + '.env.base': outdent` + # @import(./.env.common) + # --- + Y=base-y + `, + '.env.overlay': outdent` + # @import(./.env.common) + # --- + Z=overlay-z + `, + }, + expectValues: { + X: 'main-x', + Y: 'common-y', // common via overlay (higher priority) beats base's override + Z: 'overlay-z', // overlay's own definition beats its import of common + }, + })); }); }); From 2c96ae560e6c4e3fe3801c862d1eebed96417b2c Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Mon, 6 Apr 2026 21:06:51 -0700 Subject: [PATCH 5/7] Fix typecheck: add 'import-alias' to DataSourceType union Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/varlock/src/env-graph/lib/data-source.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/varlock/src/env-graph/lib/data-source.ts b/packages/varlock/src/env-graph/lib/data-source.ts index c2a9532e..43ff1169 100644 --- a/packages/varlock/src/env-graph/lib/data-source.ts +++ b/packages/varlock/src/env-graph/lib/data-source.ts @@ -35,6 +35,8 @@ const DATA_SOURCE_TYPES = Object.freeze({ }, container: { }, + 'import-alias': { + }, }); type DataSourceType = keyof typeof DATA_SOURCE_TYPES; From 7a5d86d55e1a991ed9b3557aaec9d31dabc7ba85 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Mon, 6 Apr 2026 21:40:53 -0700 Subject: [PATCH 6/7] Fix smoke-test CI: use env vars to avoid shell injection from PR title PR titles containing quotes broke the bash conditional. Using env vars instead of inline interpolation prevents shell parsing issues. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/smoke-test.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/smoke-test.yaml b/.github/workflows/smoke-test.yaml index aa5e5f87..11269245 100644 --- a/.github/workflows/smoke-test.yaml +++ b/.github/workflows/smoke-test.yaml @@ -20,8 +20,12 @@ jobs: is-release-pr: ${{ steps.check.outputs.is-release-pr }} steps: - id: check + env: + PR_TITLE: ${{ github.event.pull_request.title }} + HAS_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'smoke-tests') }} + EVENT_NAME: ${{ github.event_name }} run: | - if [[ "${{ github.event.pull_request.title }}" == *"[Changesets]"* ]] || [[ "${{ contains(github.event.pull_request.labels.*.name, 'smoke-tests') }}" == "true" ]] || [[ "${{ github.event_name }}" == "workflow_dispatch" ]] || [[ "${{ github.event_name }}" == "push" ]]; then + if [[ "$PR_TITLE" == *"[Changesets]"* ]] || [[ "$HAS_LABEL" == "true" ]] || [[ "$EVENT_NAME" == "workflow_dispatch" ]] || [[ "$EVENT_NAME" == "push" ]]; then echo "is-release-pr=true" >> $GITHUB_OUTPUT else echo "is-release-pr=false" >> $GITHUB_OUTPUT From b89789cbc5d9105854f215278384b748bbce357c Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Mon, 6 Apr 2026 21:43:37 -0700 Subject: [PATCH 7/7] Fix plugin typecheck: add node types to plugin tsconfig The plugin tsconfig was missing node type definitions, causing typecheck failures for any plugin importing node built-ins. Co-Authored-By: Claude Opus 4.6 (1M context) --- bun.lock | 8 ++++---- packages/tsconfig/plugin.tsconfig.json | 5 ++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/bun.lock b/bun.lock index 9a1c38b2..6246abf5 100644 --- a/bun.lock +++ b/bun.lock @@ -59,7 +59,7 @@ }, "packages/integrations/astro": { "name": "@varlock/astro-integration", - "version": "0.2.6", + "version": "0.2.7", "devDependencies": { "@types/node": "catalog:", "@varlock/vite-integration": "workspace:*", @@ -130,7 +130,7 @@ }, "packages/integrations/vite": { "name": "@varlock/vite-integration", - "version": "0.2.8", + "version": "0.2.9", "devDependencies": { "@types/node": "catalog:", "ast-matcher": "^1.2.0", @@ -355,7 +355,7 @@ }, "packages/varlock": { "name": "varlock", - "version": "0.7.0", + "version": "0.7.1", "bin": { "varlock": "./bin/cli.js", }, @@ -425,7 +425,7 @@ }, "packages/vscode-plugin": { "name": "env-spec-language", - "version": "0.1.0", + "version": "0.1.1", "devDependencies": { "@types/node": "catalog:", "@types/vscode": "^1.99.0", diff --git a/packages/tsconfig/plugin.tsconfig.json b/packages/tsconfig/plugin.tsconfig.json index d6c81ba4..7b42a0bc 100644 --- a/packages/tsconfig/plugin.tsconfig.json +++ b/packages/tsconfig/plugin.tsconfig.json @@ -1,3 +1,6 @@ { - "extends": "./base.tsconfig.json" + "extends": "./base.tsconfig.json", + "compilerOptions": { + "types": ["node"] + } }