Skip to content
4 changes: 2 additions & 2 deletions packages/core/src/bundle/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function bundleConfig(
document: Document,
resolvedRefMap: ResolvedRefMap,
plugins: Plugin[]
): ResolvedConfig {
): { config: ResolvedConfig; problems: NormalizedProblem[] } {
const visitorsData: ConfigBundlerVisitorData = { plugins };
const ctx: BundleContext = {
problems: [],
Expand All @@ -73,7 +73,7 @@ export function bundleConfig(
ctx,
});

return document.parsed ?? {};
return { config: document.parsed ?? {}, problems: ctx.problems };
}

export async function bundle(
Expand Down
84 changes: 84 additions & 0 deletions packages/core/src/config/__tests__/bundle-extends.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { bundleExtends } from '../bundle-extends.js';

import type { Plugin, RawGovernanceConfig } from '../types.js';
import type { UserContext } from '../../walk.js';

describe('bundleExtends', () => {
const makeCtx = () =>
({
resolve: vi.fn(),
getVisitorData: vi.fn(),
report: vi.fn(),
location: {
source: { absoluteRef: 'redocly.yaml' } as any,
pointer: '#/rules',
child: vi.fn().mockReturnThis(),
},
} as unknown as UserContext);

const dummyPlugins: Plugin[] = [];

it('should silently skip extends entry that is not a string (e.g., number)', () => {
const ctx = makeCtx();
const node = {
extends: [42],
} as unknown as RawGovernanceConfig;

const result = bundleExtends({ node, ctx, plugins: dummyPlugins });

// Bundling is best-effort; validation happens in lintConfig via ConfigNoUnresolvedRefs
expect(ctx.report).not.toHaveBeenCalled();
// The invalid entry should be filtered out
expect(result.extends).toBeUndefined();
});

it('should silently skip extends entry that is an empty string', () => {
const ctx = makeCtx();
const node = {
extends: [' '],
} as unknown as RawGovernanceConfig;

const result = bundleExtends({ node, ctx, plugins: dummyPlugins });

// Should not report errors for empty strings - just filter them out
expect(ctx.report).not.toHaveBeenCalled();
// The invalid entry should be filtered out
expect(result.extends).toBeUndefined();
});

it('should silently skip an extends entry that cannot be resolved as a file or URL', () => {
const baseCtx = makeCtx();
const node = {
extends: ['missing-config.yaml'],
} as unknown as RawGovernanceConfig;

const ctx = {
...baseCtx,
resolve: vi.fn().mockReturnValue({
location: undefined,
node: undefined,
}),
} as unknown as UserContext;

const result = bundleExtends({ node, ctx, plugins: dummyPlugins });

// Should not report errors - let downstream validators handle it
expect(baseCtx.report).not.toHaveBeenCalled();
// The unresolved entry should be filtered out
expect(result.extends).toBeUndefined();
});

it('should silently skip an extends entry that is undefined or null', () => {
const ctx = makeCtx();
const node = {
extends: [undefined, null],
} as unknown as RawGovernanceConfig;

const result = bundleExtends({ node, ctx, plugins: dummyPlugins });

// Should not report errors for undefined/null - just skip them
expect(ctx.report).not.toHaveBeenCalled();
// The undefined/null entries should be filtered out
expect(result.extends).toBeUndefined();
});
});
27 changes: 23 additions & 4 deletions packages/core/src/config/bundle-extends.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,36 @@ export function bundleExtends({
return node;
}

const resolvedExtends = (node.extends || [])
.map((presetItem: string) => {
const extendsArray = node.extends || [];

const resolvedExtends = extendsArray
.map((presetItem) => {
if (
presetItem === undefined ||
presetItem === null ||
typeof presetItem !== 'string' ||
!presetItem.trim()
) {
return undefined;
}

// Named presets: merge their configs if they exist; ignore errors here.
if (!isAbsoluteUrl(presetItem) && !path.extname(presetItem)) {
return resolvePreset(presetItem, plugins);
try {
return resolvePreset(presetItem, plugins) as RawGovernanceConfig | null;
} catch {
// Invalid preset names are reported during lintConfig; bundling stays best-effort.
return undefined;
}
}

const resolvedRef = ctx.resolve({ $ref: presetItem });

if (resolvedRef.location && resolvedRef.node !== undefined) {
return resolvedRef.node as RawGovernanceConfig;
}
return null;

return undefined;
})
.filter(isTruthy);

Expand Down
47 changes: 35 additions & 12 deletions packages/core/src/config/config-resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import type {
ImportedPlugin,
} from './types.js';
import type { Document, ResolvedRefMap } from '../resolve.js';
import type { UserContext } from '../walk.js';
import type { Location } from '../ref-utils.js';

// Cache instantiated plugins during a single execution
const pluginsCache: Map<string, Plugin[]> = new Map();
Expand Down Expand Up @@ -99,7 +101,7 @@ export async function resolveConfig({
resolvedPlugins = [...plugins, defaultPlugin];
}

const bundledConfig = bundleConfig(
const { config: bundledConfig } = bundleConfig(
rootDocument,
deepCloneMapWithJSON(resolvedRefMap),
resolvedPlugins
Expand Down Expand Up @@ -461,24 +463,45 @@ export async function resolvePlugins(
return instances.filter(isDefined).flat();
}

export function resolvePreset(presetName: string, plugins: Plugin[]): RawGovernanceConfig {
export function resolvePreset(
presetName: string,
plugins: Plugin[],
ctx?: UserContext,
location?: Location
): RawGovernanceConfig | null {
const { pluginId, configName } = parsePresetName(presetName);
const plugin = plugins.find((p) => p.id === pluginId);
if (!plugin) {
throw new Error(
`Invalid config ${colorize.red(presetName)}: plugin ${pluginId} is not included.`
);
const message = `Invalid config ${colorize.red(
presetName
)}: plugin ${pluginId} is not included.`;
if (ctx && location) {
ctx.report({
message,
location,
forceSeverity: 'warn',
});
return null;
}
throw new Error(message);
}

const preset = plugin.configs?.[configName];
if (!preset) {
throw new Error(
pluginId
? `Invalid config ${colorize.red(
presetName
)}: plugin ${pluginId} doesn't export config with name ${configName}.`
: `Invalid config ${colorize.red(presetName)}: there is no such built-in config.`
);
const message = pluginId
? `Invalid config ${colorize.red(
presetName
)}: plugin ${pluginId} doesn't export config with name ${configName}.`
: `Invalid config ${colorize.red(presetName)}: there is no such built-in config.`;
if (ctx && location) {
ctx.report({
message,
location,
forceSeverity: 'warn',
});
return null;
}
throw new Error(message);
}
return preset;
}
11 changes: 7 additions & 4 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,16 +169,19 @@ export class Config {
saveIgnore() {
const dir = this.configPath ? path.dirname(this.configPath) : process.cwd();
const ignoreFile = path.join(dir, IGNORE_FILE);
const mapped: Record<string, any> = {};
const mapped: Record<string, Record<string, string[]>> = {};
for (const absFileName of Object.keys(this.ignore)) {
const mappedDefinitionName = isAbsoluteUrl(absFileName)
? absFileName
: slash(path.relative(dir, absFileName));
const ignoredRules = (mapped[mappedDefinitionName] = this.ignore[absFileName]);
const sourceRules = this.ignore[absFileName];
const ignoredRules: Record<string, string[]> = {};

for (const ruleId of Object.keys(ignoredRules)) {
ignoredRules[ruleId] = Array.from(ignoredRules[ruleId]) as any;
for (const ruleId of Object.keys(sourceRules)) {
ignoredRules[ruleId] = Array.from(sourceRules[ruleId]);
}

mapped[mappedDefinitionName] = ignoredRules;
}
fs.writeFileSync(ignoreFile, IGNORE_BANNER + stringifyYaml(mapped));
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/config/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export async function loadConfig(
const config = new Config(resolvedConfig, {
configPath,
document: rawConfigDocument,
resolvedRefMap: resolvedRefMap,
resolvedRefMap,
plugins,
ignore,
});
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { createEntityTypes, ENTITY_DISCRIMINATOR_NAME } from './types/entity-yam
import { Struct } from './rules/common/struct.js';
import { NoUnresolvedRefs } from './rules/common/no-unresolved-refs.js';
import { EntityKeyValid } from './rules/catalog-entity/entity-key-valid.js';
import { ConfigNoUnresolvedRefs } from './rules/config/config-no-unresolved-refs.js';
import { type Config } from './config/index.js';
import { isPlainObject } from './utils/is-plain-object.js';

Expand Down Expand Up @@ -183,7 +184,7 @@ export async function lintConfig(opts: {
{
severity: severity || 'error',
ruleId: 'configuration no-unresolved-refs',
visitor: NoUnresolvedRefs({ severity: 'error' }),
visitor: ConfigNoUnresolvedRefs(),
},
];
const normalizedVisitors = normalizeVisitors(rules, types);
Expand Down
Loading
Loading