Skip to content

Commit 6ab2d31

Browse files
Copilotphilmillmantheoephraimclaude
authored
Fix diamond dependency double-import handling (#553)
* 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> * 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> * 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) <noreply@anthropic.com> * Fix typecheck: add 'import-alias' to DataSourceType union Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: philmillman <3722211+philmillman@users.noreply.github.com> Co-authored-by: Theo Ephraim <theo@dmno.dev> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0b6b2c0 commit 6ab2d31

11 files changed

Lines changed: 573 additions & 20 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"varlock": patch
3+
"@varlock/nextjs-integration": patch
4+
---
5+
6+
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.

.github/workflows/smoke-test.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,12 @@ jobs:
2020
is-release-pr: ${{ steps.check.outputs.is-release-pr }}
2121
steps:
2222
- id: check
23+
env:
24+
PR_TITLE: ${{ github.event.pull_request.title }}
25+
HAS_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'smoke-tests') }}
26+
EVENT_NAME: ${{ github.event_name }}
2327
run: |
24-
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
28+
if [[ "$PR_TITLE" == *"[Changesets]"* ]] || [[ "$HAS_LABEL" == "true" ]] || [[ "$EVENT_NAME" == "workflow_dispatch" ]] || [[ "$EVENT_NAME" == "push" ]]; then
2529
echo "is-release-pr=true" >> $GITHUB_OUTPUT
2630
else
2731
echo "is-release-pr=false" >> $GITHUB_OUTPUT

bun.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/integrations/nextjs/src/next-env-compat.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,7 @@ let rootDir: string | undefined;
3535
// a list of filenames loaded, for example: `Environments: .env, .env.development`
3636
function getVarlockSourcesAsLoadedEnvFiles(): LoadedEnvFiles {
3737
const envFilesLabels = varlockLoadedEnv.sources
38-
// TODO expose more info so we can filter out disabled sources
39-
// and maybe show relative paths
40-
.filter((s) => s.enabled && !s.label.startsWith('directory -'))
38+
.filter((s) => s.enabled && s.type !== 'container' && s.type !== 'import-alias')
4139
.map((s) => s.label);
4240
if (envFilesLabels.length) {
4341
// this adds an additional line, below the list of files
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
{
2-
"extends": "./base.tsconfig.json"
2+
"extends": "./base.tsconfig.json",
3+
"compilerOptions": {
4+
"types": ["node"]
5+
}
36
}

packages/varlock/src/env-graph/lib/config-item.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,19 @@ export class ConfigItem {
4545
}
4646

4747
/**
48-
* fetch ordered list of definitions for this item, by following up sorted data sources list
48+
* fetch ordered list of definitions for this item, by following the sorted definition sources list
4949
* internal defs (builtins) are appended last (lowest priority)
5050
*/
5151
get defs() {
5252
// TODO: this is somewhat inneficient, because some of the checks on the data source follow up the parent chain
5353
// we may want to cache the definition list at some point when loading is complete
5454
// although we need it to be dynamic during the loading process when doing any early resolution of the envFlag
5555
const defs: Array<ConfigItemDefAndSource> = [];
56-
for (const source of this.envGraph.sortedDataSources) {
56+
for (const { source, importKeys } of this.envGraph.sortedDefinitionSources) {
5757
if (!source.configItemDefs[this.key]) continue;
5858
if (source.disabled) continue;
59-
if (source.importKeys && !source.importKeys.includes(this.key)) continue;
60-
const itemDef = source.configItemDefs[this.key];
61-
if (itemDef) defs.push({ itemDef, source });
59+
if (importKeys && !importKeys.includes(this.key)) continue;
60+
defs.push({ itemDef: source.configItemDefs[this.key], source });
6261
}
6362
defs.push(...this._internalDefs);
6463
return defs;

packages/varlock/src/env-graph/lib/data-source.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ const DATA_SOURCE_TYPES = Object.freeze({
3535
},
3636
container: {
3737
},
38+
'import-alias': {
39+
},
3840
});
3941
type DataSourceType = keyof typeof DATA_SOURCE_TYPES;
4042

@@ -386,6 +388,18 @@ export abstract class EnvGraphDataSource {
386388
const fileName = path.basename(fullImportPath);
387389

388390
// TODO: might be nice to move this logic somewhere else
391+
// Check for diamond dependency: if this path was already loaded,
392+
// add an alias instead of re-loading (prevents double plugin init)
393+
const existingSource = this.graph.getLoadedImportSource(fullImportPath);
394+
if (existingSource) {
395+
// eslint-disable-next-line no-use-before-define
396+
await this.addChild(new ImportAliasSource(existingSource), {
397+
isImport: true, importKeys, isConditionallyEnabled,
398+
});
399+
this.graph.registerItemsForImport(existingSource, this, importKeys);
400+
continue;
401+
}
402+
389403
if (this.graph.virtualImports) {
390404
if (importPath.endsWith('/')) {
391405
const dirExists = Object.keys(this.graph.virtualImports).some((p) => p.startsWith(fullImportPath));
@@ -395,9 +409,11 @@ export abstract class EnvGraphDataSource {
395409
return;
396410
}
397411
// eslint-disable-next-line no-use-before-define
398-
await this.addChild(new DirectoryDataSource(fullImportPath), {
412+
const dirChild = new DirectoryDataSource(fullImportPath);
413+
await this.addChild(dirChild, {
399414
isImport: true, importKeys, isConditionallyEnabled,
400415
});
416+
this.graph.recordLoadedImportPath(fullImportPath, dirChild);
401417
} else {
402418
const fileExists = this.graph.virtualImports[fullImportPath];
403419
if (!fileExists && allowMissing) continue;
@@ -406,10 +422,11 @@ export abstract class EnvGraphDataSource {
406422
return;
407423
}
408424
// eslint-disable-next-line no-use-before-define
409-
const source = new DotEnvFileDataSource(fullImportPath, {
425+
const fileChild = new DotEnvFileDataSource(fullImportPath, {
410426
overrideContents: this.graph.virtualImports[fullImportPath],
411427
});
412-
await this.addChild(source, { isImport: true, importKeys, isConditionallyEnabled });
428+
await this.addChild(fileChild, { isImport: true, importKeys, isConditionallyEnabled });
429+
this.graph.recordLoadedImportPath(fullImportPath, fileChild);
413430
}
414431
} else {
415432
const fsStat = await tryCatch(async () => fs.stat(fullImportPath), (_err) => {
@@ -426,9 +443,11 @@ export abstract class EnvGraphDataSource {
426443
if (importPath.endsWith('/')) {
427444
if (fsStat.isDirectory()) {
428445
// eslint-disable-next-line no-use-before-define
429-
await this.addChild(new DirectoryDataSource(fullImportPath), {
446+
const dirChild = new DirectoryDataSource(fullImportPath);
447+
await this.addChild(dirChild, {
430448
isImport: true, importKeys, isConditionallyEnabled,
431449
});
450+
this.graph.recordLoadedImportPath(fullImportPath, dirChild);
432451
} else {
433452
this._loadingError = new Error(`Imported path ending with "/" is not a directory: ${fullImportPath}`);
434453
return;
@@ -444,9 +463,11 @@ export abstract class EnvGraphDataSource {
444463
}
445464
// TODO: once we have more file types, here we would detect the type and import it correctly
446465
// eslint-disable-next-line no-use-before-define
447-
await this.addChild(new DotEnvFileDataSource(fullImportPath), {
466+
const fileChild = new DotEnvFileDataSource(fullImportPath);
467+
await this.addChild(fileChild, {
448468
isImport: true, importKeys, isConditionallyEnabled,
449469
});
470+
this.graph.recordLoadedImportPath(fullImportPath, fileChild);
450471
}
451472
}
452473
} else if (importPath.startsWith('http://') || importPath.startsWith('https://')) {
@@ -546,6 +567,32 @@ export abstract class EnvGraphDataSource {
546567
}
547568
}
548569

570+
/**
571+
* Lightweight alias created when the same path is imported from multiple locations.
572+
* Lives in the tree like any other child (has its own importMeta/parent) but delegates
573+
* definitions to the original source. Has no rootDecorators or configItemDefs of its own,
574+
* so sortedDataSources consumers (plugin init, error collection, etc.) naturally skip it.
575+
*/
576+
export class ImportAliasSource extends EnvGraphDataSource {
577+
type = 'import-alias' as const;
578+
typeLabel = 'import-alias';
579+
580+
constructor(
581+
/** the real data source this alias points to */
582+
readonly original: EnvGraphDataSource,
583+
) {
584+
super();
585+
}
586+
587+
get label() { return `re-import of ${this.original.label}`; }
588+
589+
// no-op — the original was already fully initialized
590+
// eslint-disable-next-line @typescript-eslint/no-empty-function
591+
async _finishInit() {}
592+
// eslint-disable-next-line @typescript-eslint/no-empty-function
593+
async _processImports() {}
594+
}
595+
549596
export abstract class FileBasedDataSource extends EnvGraphDataSource {
550597
fullPath: string;
551598
fileName: string;

packages/varlock/src/env-graph/lib/env-graph.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import _ from '@env-spec/utils/my-dash';
22
import path from 'node:path';
33
import { ConfigItem } from './config-item';
4-
import { EnvGraphDataSource, FileBasedDataSource } from './data-source';
4+
import { EnvGraphDataSource, FileBasedDataSource, ImportAliasSource } from './data-source';
55

66
import { BaseResolvers, createResolver, type ResolverChildClass } from './resolver';
77
import { BaseDataTypes, type EnvGraphDataTypeFactory } from './data-types';
@@ -27,9 +27,18 @@ export type SerializedEnvGraphErrors = {
2727
root?: Array<string>;
2828
};
2929

30+
/** Entry in the sorted definition sources list — pairs a data source with the importKeys
31+
* filter that applies at that specific position in the precedence chain */
32+
export type DefinitionSourceEntry = {
33+
source: EnvGraphDataSource;
34+
/** importKeys filter for this position (undefined = all keys visible) */
35+
importKeys?: Array<string>;
36+
};
37+
3038
export type SerializedEnvGraph = {
3139
basePath?: string;
3240
sources: Array<{
41+
type: string;
3342
label: string;
3443
enabled: boolean;
3544
path?: string;
@@ -68,6 +77,63 @@ export class EnvGraph {
6877
configSchema: Record<string, ConfigItem> = {};
6978

7079

80+
/**
81+
* Tracks directory/file paths that have already been loaded as imports.
82+
* Maps each import path to the data source that was created for it.
83+
* Used to prevent diamond-dependency re-imports (same schema imported via multiple paths),
84+
* which would otherwise cause plugin init decorators to run multiple times.
85+
*/
86+
private _loadedImportPaths = new Map<string, EnvGraphDataSource>();
87+
88+
/** Returns the existing source for a path if already loaded, or undefined */
89+
getLoadedImportSource(importPath: string): EnvGraphDataSource | undefined {
90+
return this._loadedImportPaths.get(importPath);
91+
}
92+
93+
/** Records the data source that was created for an import path */
94+
recordLoadedImportPath(importPath: string, dataSource: EnvGraphDataSource) {
95+
this._loadedImportPaths.set(importPath, dataSource);
96+
}
97+
98+
/**
99+
* Register ConfigItems for keys visible through an import
100+
* that may not have been registered during the original source's finishInit.
101+
*/
102+
registerItemsForImport(
103+
source: EnvGraphDataSource,
104+
importSite: EnvGraphDataSource,
105+
importKeys?: Array<string>,
106+
) {
107+
// Compute effective importKeys: intersection of import filter and importSite's parent chain
108+
const siteKeys = importSite.importKeys;
109+
let effectiveKeys: Array<string> | undefined;
110+
const hasFilter = importKeys && importKeys.length > 0;
111+
if (hasFilter && siteKeys?.length) {
112+
effectiveKeys = importKeys.filter((k) => siteKeys.includes(k));
113+
} else if (hasFilter) {
114+
effectiveKeys = importKeys;
115+
} else {
116+
effectiveKeys = siteKeys;
117+
}
118+
119+
for (const s of this._getDescendants(source)) {
120+
const keys = effectiveKeys || _.keys(s.configItemDefs);
121+
for (const itemKey of keys) {
122+
if (!s.configItemDefs[itemKey]) continue;
123+
this.configSchema[itemKey] ??= new ConfigItem(this, itemKey);
124+
}
125+
}
126+
}
127+
128+
/** Get a data source and all its descendants (DFS) */
129+
private _getDescendants(source: EnvGraphDataSource): Array<EnvGraphDataSource> {
130+
const result: Array<EnvGraphDataSource> = [source];
131+
for (const child of source.children) {
132+
result.push(...this._getDescendants(child));
133+
}
134+
return result;
135+
}
136+
71137
/** virtual imports for testing */
72138
virtualImports?: Record<string, string>;
73139
setVirtualImports(basePath: string, files: Record<string, string>) {
@@ -85,6 +151,36 @@ export class EnvGraph {
85151
return this.rootDataSource ? getSourceAndChildren(this.rootDataSource) : [];
86152
}
87153

154+
/**
155+
* Precedence-ordered list of definition sources, used by ConfigItem.defs.
156+
*
157+
* Unlike `sortedDataSources` (which contains each real source exactly once),
158+
* this list can contain the same source multiple times at different positions
159+
* when it's imported from multiple locations (diamond dependency). Each entry
160+
* carries its own `importKeys` filter for that specific import context.
161+
*
162+
* Built from `sortedDataSources` by expanding `ImportAliasSource` nodes into
163+
* the original source's full subtree at the alias's precedence position.
164+
*/
165+
get sortedDefinitionSources(): Array<DefinitionSourceEntry> {
166+
const result: Array<DefinitionSourceEntry> = [];
167+
168+
for (const source of this.sortedDataSources) {
169+
if (source instanceof ImportAliasSource) {
170+
// Alias: expand to the original source's subtree at this position,
171+
// using the alias's importKeys (derived from its own parent chain)
172+
const importKeys = source.importKeys;
173+
for (const descendant of this._getDescendants(source.original)) {
174+
result.push({ source: descendant, importKeys });
175+
}
176+
} else {
177+
result.push({ source, importKeys: source.importKeys });
178+
}
179+
}
180+
181+
return result;
182+
}
183+
88184
registeredResolverFunctions: Record<string, ResolverChildClass> = {};
89185
registerResolver(resolverClass: ResolverChildClass) {
90186
// because its a class, we can't use `name`
@@ -439,6 +535,7 @@ export class EnvGraph {
439535
};
440536
for (const source of this.sortedDataSources) {
441537
serializedGraph.sources.push({
538+
type: source.type,
442539
label: source.label,
443540
enabled: !source.disabled,
444541
path: source instanceof FileBasedDataSource ? path.relative(this.basePath ?? '', source.fullPath) : undefined,

0 commit comments

Comments
 (0)