Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .bumpy/forenv-validate-env-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
varlock: patch
---

Warn when a `forEnv()` argument is not one of the `@currentEnv` enum values, with a "did you mean" hint, so a typo like `forEnv(prodution)` no longer silently never matches
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@ DEV_API_KEY=
# @required=forEnv(staging, production)
PROD_API_KEY=
```

If the `@currentEnv` item is typed as an `enum`, varlock warns about any argument written out literally that is not one of the enum values, because it can never match. For example, `forEnv(prodution)` is reported instead of silently never being true, and the warning suggests the closest enum value when there is one (within two edits). Arguments that are not literal, such as `forEnv($OTHER_ITEM)`, are not checked, and neither is an enum whose values are computed.
</div>


Expand Down
35 changes: 3 additions & 32 deletions packages/varlock/src/cli/helpers/arg-errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CliExitError } from './exit-error';
import { fmt } from './pretty-format';
import { suggestClosest as suggestClosestName } from '../../lib/suggest-closest';

// Errors about the CLI arguments themselves - unrelated to the env-graph's ValidationError,
// which is about config item values failing their schema.
Expand Down Expand Up @@ -29,46 +30,16 @@ type CommandNotFoundError = {
/** gunshi's placeholder name for the unnamed entry command; never something to suggest */
const ANONYMOUS_COMMAND_NAME = '(anonymous)';

/**
* Levenshtein distance, kept dependency-free for the "did you mean" suggestion.
*
* @internal exported for unit tests
*/
export function levenshtein(a: string, b: string): number {
const rows = a.length + 1;
const cols = b.length + 1;
const dist: Array<Array<number>> = Array.from({ length: rows }, () => new Array(cols).fill(0));
for (let i = 0; i < rows; i++) dist[i][0] = i;
for (let j = 0; j < cols; j++) dist[0][j] = j;
for (let i = 1; i < rows; i++) {
for (let j = 1; j < cols; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
dist[i][j] = Math.min(
dist[i - 1][j] + 1,
dist[i][j - 1] + 1,
dist[i - 1][j - 1] + cost,
);
}
}
return dist[a.length][b.length];
}

const stripDashes = (value: string) => value.replace(/^--?/, '');

/**
* Closest candidate within edit distance 2, or undefined when nothing is close enough.
* Leading dashes are ignored so `--alow` matches `--allow`.
*
* @internal exported for unit tests
*/
export function suggestClosest(typed: string, candidates: Array<string>, maxDistance = 2) {
let best: { name: string, distance: number } | undefined;
for (const candidate of candidates) {
const distance = levenshtein(stripDashes(typed), stripDashes(candidate));
if (distance <= maxDistance && (!best || distance < best.distance)) {
best = { name: candidate, distance };
}
}
return best?.name;
return suggestClosestName(typed, candidates, maxDistance, stripDashes);
}

// Errors arrive from gunshi's own bundle, so `instanceof` against the classes re-exported
Expand Down
2 changes: 1 addition & 1 deletion packages/varlock/src/cli/helpers/test/arg-errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { levenshtein } from '../../../lib/suggest-closest';
import {
levenshtein,
suggestClosest,
commandPathFromArgs,
isArgError,
Expand Down
27 changes: 27 additions & 0 deletions packages/varlock/src/cli/helpers/test/error-checks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,31 @@ describe('checkForSchemaErrors prints each error once', () => {
expect(countOccurrences(output, err.message), err.message).toBe(1);
}
});

it('shows the did-you-mean for a forEnv() typo in a root decorator', async () => {
const g = await loadGraph(outdent`
# @currentEnv=$APP_ENV
# @import(./.env.extra, enabled=forEnv(prodution), allowMissing=true)
# ---
# @type=enum(development, production)
# @public
APP_ENV=production
`);
const output = captureOutput(() => checkForSchemaErrors(g, { noThrow: true }));
expect(output).toContain('"prodution" is not a possible value of APP_ENV');
expect(output).toContain('Did you mean "production"?');
});

it('lists the possible values for a forEnv() root decorator with no close match', async () => {
const g = await loadGraph(outdent`
# @currentEnv=$APP_ENV
# @import(./.env.extra, enabled=forEnv(staging), allowMissing=true)
# ---
# @type=enum(development, production)
# @public
APP_ENV=production
`);
const output = captureOutput(() => checkForSchemaErrors(g, { noThrow: true }));
expect(output).toContain('"staging" is not a possible value of APP_ENV (the @currentEnv item: development, production)');
});
});
20 changes: 19 additions & 1 deletion packages/varlock/src/env-graph/lib/env-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { ConfigItem, type TypeGenItemInfo } from './config-item';
import { EnvGraphDataSource, FileBasedDataSource, ImportAliasSource } from './data-source';
import { computeFilteredKeys, type ParsedItemFilter } from './item-filter';

import { BaseResolvers, createResolver, type ResolverChildClass } from './resolver';
import {
BaseResolvers, checkForEnvArgs, createResolver, type Resolver, type ResolverChildClass,
} from './resolver';
import { BaseDataTypes, type EnvGraphDataTypeFactory } from './data-types';
import { findGraphCycles, getTransitiveDeps, type GraphAdjacencyList } from './graph-utils';
import { ResolutionError, SchemaError, ValidationError } from './errors';
Expand Down Expand Up @@ -258,6 +260,12 @@ export class EnvGraph {

configSchema: Record<string, ConfigItem> = {};

/**
* every forEnv() call seen while processing, with the key of the @currentEnv item it
* evaluates against, checked once all item types are known
*/
forEnvCalls = new Map<Resolver, string | undefined>();


/**
* Tracks directory/file paths that have already been loaded as imports.
Expand Down Expand Up @@ -780,6 +788,16 @@ export class EnvGraph {
}
}
this.checkBootDynamicItems();
// grouped by @currentEnv item, so each item's env names are read once
const forEnvCallsByEnvKey = new Map<string, Array<Resolver>>();
this.forEnvCalls.forEach((envKey, call) => {
if (!envKey) return;
const calls = forEnvCallsByEnvKey.get(envKey);
if (calls) calls.push(call);
else forEnvCallsByEnvKey.set(envKey, [call]);
});
forEnvCallsByEnvKey.forEach((calls, envKey) => checkForEnvArgs(calls, this.configSchema[envKey]));
this.forEnvCalls.clear();

// now execute all root decorators
for (const source of this.sortedDataSources) {
Expand Down
40 changes: 40 additions & 0 deletions packages/varlock/src/env-graph/lib/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { DecoratorInstance } from './decorators';
import { getErrorLocation } from './error-location';
import { isBuiltinVar } from './builtin-vars';
import { REGEX_LIKE_STRING, parseRegexLikeString } from './regex-like-string';
import { suggestClosest } from '../../lib/suggest-closest';

const execAsync = promisify(exec);

Expand Down Expand Up @@ -692,6 +693,11 @@ export const ForEnvResolver: typeof Resolver = createResolver({
arrayMinLength: 1,
},
process() {
// The args are checked against the @currentEnv item's enum once all items are typed (see
// checkForEnvArgs). Which item that is has to be read now: @disable is processed before
// the file's own @currentEnv takes effect, so it uses the parent's.
const forEnvCalls = this.envGraph?.forEnvCalls;
if (forEnvCalls && !forEnvCalls.has(this)) forEnvCalls.set(this, this.dataSource?.envFlagKey);
return this.arrArgs!;
},
async resolve(matchEnvArgs) {
Expand All @@ -712,6 +718,40 @@ export const ForEnvResolver: typeof Resolver = createResolver({
},
});

/**
* A static `forEnv()` arg that is not a member of the @currentEnv item's enum can never match,
* so the call is dead (`@required=forEnv(prodution)` silently never requires anything).
* Reported as a warning, not an error, since a schema with a stale arg (an env dropped from
* the enum) loads fine today.
*
* Only checked when the item is a plain `enum(...)` whose members are written literally.
* Dynamic args (`forEnv($X)`) and any other type of env item are left alone.
*
* Everything goes in the message rather than the tip, because warnings on a data source
* (a root decorator's) are printed without their tip.
*/
export function checkForEnvArgs(forEnvCalls: Array<Resolver>, envItem?: ConfigItem) {
if (!envItem || envItem.hasComputedType) return;
const members = envItem.dataType?.coercedType;
if (typeof members !== 'object' || !('enum' in members)) return;
const envNames = members.enum.map(String);
const envNameSet = new Set(envNames);

for (const resolver of forEnvCalls) {
for (const arg of resolver.arrArgs ?? []) {
if (!(arg instanceof StaticValueResolver)) continue;
const argName = String(arg.staticValue);
if (envNameSet.has(argName)) continue;
const suggestion = suggestClosest(argName, envNames);
resolver._errors.push(new SchemaError(
`${resolver.fnName}(): "${argName}" is not a possible value of ${envItem.key} (the @currentEnv item: ${envNames.join(', ')}), so it can never match`
+ `${suggestion ? `. Did you mean "${suggestion}"?` : ''}`,
{ isWarning: true },
));
}
}
}

export const EqResolver: typeof Resolver = createResolver({
name: 'eq',
icon: 'material-symbols:equal',
Expand Down
Loading
Loading