Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/spec/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export { defineAgent } from './ai/agent.zod';
// DX Validation Utilities (re-exported for convenience)
export { objectStackErrorMap, formatZodError, formatZodIssue, safeParsePretty } from './shared/error-map.zod';
export { suggestFieldType, findClosestMatches, formatSuggestion } from './shared/suggestions.zod';
export { normalizeMetadataCollection, normalizeStackInput, MAP_SUPPORTED_FIELDS } from './shared/metadata-collection.zod';
export { normalizeMetadataCollection, normalizeStackInput, normalizePluginMetadata, MAP_SUPPORTED_FIELDS, METADATA_ALIASES } from './shared/metadata-collection.zod';
export type { MetadataCollectionInput, MapSupportedField } from './shared/metadata-collection.zod';

export { type PluginContext } from './kernel/plugin.zod';
Expand Down
143 changes: 143 additions & 0 deletions packages/spec/src/shared/metadata-collection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { describe, it, expect } from 'vitest';
import {
normalizeMetadataCollection,
normalizeStackInput,
normalizePluginMetadata,
MAP_SUPPORTED_FIELDS,
METADATA_ALIASES,
} from './metadata-collection.zod';

describe('normalizeMetadataCollection', () => {
Expand Down Expand Up @@ -260,3 +262,144 @@ describe('MAP_SUPPORTED_FIELDS', () => {
expect(MAP_SUPPORTED_FIELDS).not.toContain('devPlugins');
});
});

describe('METADATA_ALIASES', () => {
it('should map triggers to hooks', () => {
expect(METADATA_ALIASES.triggers).toBe('hooks');
});
});

describe('normalizePluginMetadata', () => {
describe('map → array conversion', () => {
it('should convert map-formatted actions to an array', () => {
const result = normalizePluginMetadata({
actions: {
lead_convert: { type: 'custom', label: 'Convert Lead' },
},
});
expect(result.actions).toEqual([
{ name: 'lead_convert', type: 'custom', label: 'Convert Lead' },
]);
});

it('should convert map-formatted workflows to an array', () => {
const result = normalizePluginMetadata({
workflows: {
auto_assign: { objectName: 'lead', label: 'Auto Assign' },
},
});
expect(result.workflows).toEqual([
{ name: 'auto_assign', objectName: 'lead', label: 'Auto Assign' },
]);
});

it('should leave array-formatted collections unchanged', () => {
const actions = [{ name: 'convert', type: 'custom' }];
const result = normalizePluginMetadata({ actions });
expect(result.actions).toBe(actions);
});

it('should handle multiple map-formatted collections at once', () => {
const result = normalizePluginMetadata({
actions: { a: { label: 'A' } },
flows: { f: { label: 'F' } },
hooks: { h: { object: 'lead' } },
});
expect(result.actions).toEqual([{ name: 'a', label: 'A' }]);
expect(result.flows).toEqual([{ name: 'f', label: 'F' }]);
expect(result.hooks).toEqual([{ name: 'h', object: 'lead' }]);
});
});

describe('alias resolution (triggers → hooks)', () => {
it('should rename triggers to hooks', () => {
const result = normalizePluginMetadata({
triggers: {
lead_scoring: { object: 'lead', event: 'afterInsert' },
},
});
expect(result.hooks).toEqual([
{ name: 'lead_scoring', object: 'lead', event: 'afterInsert' },
]);
expect(result.triggers).toBeUndefined();
});

it('should merge triggers into existing hooks (array)', () => {
const result = normalizePluginMetadata({
hooks: [{ name: 'existing_hook', object: 'account' }],
triggers: {
new_trigger: { object: 'lead', event: 'afterInsert' },
},
});
expect(result.hooks).toEqual([
{ name: 'existing_hook', object: 'account' },
{ name: 'new_trigger', object: 'lead', event: 'afterInsert' },
]);
expect(result.triggers).toBeUndefined();
});

it('should merge triggers into existing hooks (map)', () => {
const result = normalizePluginMetadata({
hooks: { existing_hook: { object: 'account' } },
triggers: { new_trigger: { object: 'lead' } },
});
expect(result.hooks).toEqual([
{ name: 'existing_hook', object: 'account' },
{ name: 'new_trigger', object: 'lead' },
]);
expect(result.triggers).toBeUndefined();
});
});

describe('recursive nested plugin normalization', () => {
it('should recursively normalize nested plugins', () => {
const result = normalizePluginMetadata({
actions: { a1: { label: 'Root Action' } },
plugins: [
{
actions: { nested_action: { label: 'Nested' } },
triggers: { nested_trigger: { object: 'contact' } },
},
'string-plugin-ref', // string refs should pass through
],
});

expect(result.actions).toEqual([{ name: 'a1', label: 'Root Action' }]);
expect(result.plugins).toHaveLength(2);

const nestedPlugin = result.plugins[0] as Record<string, unknown>;
expect(nestedPlugin.actions).toEqual([{ name: 'nested_action', label: 'Nested' }]);
expect(nestedPlugin.hooks).toEqual([{ name: 'nested_trigger', object: 'contact' }]);
expect(nestedPlugin.triggers).toBeUndefined();

expect(result.plugins[1]).toBe('string-plugin-ref');
});
});

describe('pass-through / edge cases', () => {
it('should not modify non-metadata fields', () => {
const result = normalizePluginMetadata({
manifest: { name: 'test', version: '1.0.0' },
actions: { a: { label: 'A' } },
});
expect(result.manifest).toEqual({ name: 'test', version: '1.0.0' });
});

it('should handle empty input', () => {
expect(normalizePluginMetadata({})).toEqual({});
});

it('should handle input with no metadata collections', () => {
const input = { manifest: { name: 'test' }, i18n: { defaultLocale: 'en' } };
const result = normalizePluginMetadata(input);
expect(result.manifest).toBe(input.manifest);
expect(result.i18n).toBe(input.i18n);
});

it('should handle undefined metadata fields', () => {
const result = normalizePluginMetadata({ actions: undefined, hooks: undefined });
expect(result.actions).toBeUndefined();
expect(result.hooks).toBeUndefined();
});
});
});
84 changes: 84 additions & 0 deletions packages/spec/src/shared/metadata-collection.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,87 @@ export function normalizeStackInput<T extends Record<string, unknown>>(input: T)
}
return result;
}

/**
* Mapping of legacy / alternative field names to their canonical names
* in `ObjectStackDefinitionSchema`.
*
* Plugins may use legacy names (e.g., `triggers` instead of `hooks`).
* This map lets `normalizePluginMetadata()` rewrite them automatically.
*/
export const METADATA_ALIASES: Record<string, MapSupportedField> = {
triggers: 'hooks',
};

/**
* Normalize plugin metadata so it matches the canonical format expected by the runtime.
*
* This handles two issues that commonly arise when loading third-party plugin metadata:
*
* 1. **Map → Array conversion** — plugins often define metadata as maps
* (e.g., `actions: { convert_lead: { ... } }`), but the runtime expects arrays.
* Every key listed in {@link MAP_SUPPORTED_FIELDS} is normalized via
* {@link normalizeMetadataCollection}.
*
* 2. **Field aliasing** — plugins may use legacy or alternative field names
* (e.g., `triggers` instead of `hooks`). {@link METADATA_ALIASES} maps them
* to their canonical counterparts.
*
* 3. **Recursive normalization** — if the plugin itself contains nested `plugins`,
* each nested plugin is normalized recursively.
*
* @param metadata - Raw plugin metadata object
* @returns A new object with all collections normalized to arrays, aliases resolved,
* and nested plugins recursively normalized
*
* @example
* ```ts
* const raw = {
* actions: { lead_convert: { type: 'custom', label: 'Convert' } },
* triggers: { lead_scoring: { object: 'lead', event: 'afterInsert' } },
* };
* const normalized = normalizePluginMetadata(raw);
* // normalized.actions → [{ name: 'lead_convert', type: 'custom', label: 'Convert' }]
* // normalized.hooks → [{ name: 'lead_scoring', object: 'lead', event: 'afterInsert' }]
* // normalized.triggers → removed (merged into hooks)
* ```
*/
export function normalizePluginMetadata<T extends Record<string, unknown>>(metadata: T): T {
const result = { ...metadata };

// 1. Resolve aliases (e.g. triggers → hooks), merging with any existing canonical values
for (const [alias, canonical] of Object.entries(METADATA_ALIASES)) {
if (alias in result) {
const aliasValue = normalizeMetadataCollection(result[alias]);
const canonicalValue = normalizeMetadataCollection(result[canonical]);

// Merge: canonical array wins; alias values are appended
if (Array.isArray(aliasValue)) {
(result as Record<string, unknown>)[canonical] = Array.isArray(canonicalValue)
? [...canonicalValue, ...aliasValue]
: aliasValue;
}

delete (result as Record<string, unknown>)[alias];
}
}

// 2. Normalize map-formatted collections → arrays
for (const field of MAP_SUPPORTED_FIELDS) {
if (field in result) {
(result as Record<string, unknown>)[field] = normalizeMetadataCollection(result[field]);
}
}
Comment on lines +220 to +242
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation normalizes collections twice in certain cases. When an alias field exists (e.g., triggers), both the alias value (line 223) and the canonical value (line 224) are normalized. Then, all MAP_SUPPORTED_FIELDS including the canonical field are normalized again in step 2 (line 240). While normalizeMetadataCollection() is idempotent so this doesn't cause incorrect results, it does perform unnecessary work.

Consider optimizing by tracking which fields have already been normalized in step 1, or by skipping already-normalized canonical fields in step 2. For example, you could collect canonical field names from METADATA_ALIASES and skip them when iterating through MAP_SUPPORTED_FIELDS.

Copilot uses AI. Check for mistakes.

// 3. Recursively normalize nested plugins
if (Array.isArray(result.plugins)) {
(result as Record<string, unknown>).plugins = result.plugins.map((p: unknown) => {
if (p && typeof p === 'object' && !Array.isArray(p)) {
return normalizePluginMetadata(p as Record<string, unknown>);
}
return p;
});
}

return result;
}