legal-markdown-js
Version:
Node.js implementation of LegalMarkdown for processing legal documents with markdown and YAML - Complete feature parity with Ruby version
373 lines • 14.3 kB
JavaScript
/**
* Plugin Order Validator
*
* Validates that remark plugins are executed in the correct order based on
* their declared dependencies. This prevents runtime bugs caused by incorrect
* plugin ordering.
*
* @module plugins/remark/plugin-order-validator
*/
import { logger } from '../../utils/logger.js';
/**
* Validator for plugin execution order
*
* This class validates that plugins are executed in an order that satisfies
* all declared dependencies and constraints.
*
* @example
* ```typescript
* const validator = new PluginOrderValidator(registry);
* const result = validator.validate(
* ['remarkImports', 'remarkTemplateFields', 'remarkLegalHeadersParser']
* );
*
* if (!result.valid) {
* console.error('Invalid plugin order:', result.errors);
* console.log('Suggested order:', result.suggestedOrder);
* }
* ```
*/
export class PluginOrderValidator {
registry;
constructor(registry) {
this.registry = registry;
}
/**
* Validate plugin execution order
*
* @param pluginNames - List of plugin names in execution order
* @param options - Validation options
* @returns Validation result with errors and warnings
*/
validate(pluginNames, options = {}) {
const { throwOnError = true, logWarnings = true, strictMode = false, debug = false } = options;
const errors = [];
const warnings = [];
if (debug) {
logger.debug('Validating plugin order:', pluginNames);
}
// Build a position map for quick lookups
const positions = new Map();
pluginNames.forEach((name, index) => {
positions.set(name, index);
});
// Check each plugin's constraints
for (let i = 0; i < pluginNames.length; i++) {
const pluginName = pluginNames[i];
const metadata = this.registry.get(pluginName);
if (!metadata) {
if (strictMode) {
warnings.push({
type: 'unknown-plugin',
plugin: pluginName,
message: `Plugin "${pluginName}" has no metadata declaration`,
});
}
continue;
}
if (debug) {
logger.debug(`Checking constraints for ${pluginName}`);
}
// Check runBefore constraints
if (metadata.runBefore) {
for (const beforePlugin of metadata.runBefore) {
const beforePos = positions.get(beforePlugin);
if (beforePos !== undefined && beforePos <= i) {
errors.push({
type: 'dependency-violation',
plugin: pluginName,
relatedPlugin: beforePlugin,
message: `"${pluginName}" must run BEFORE "${beforePlugin}", ` +
`but it appears after it in the pipeline`,
});
}
}
}
// Check runAfter constraints
if (metadata.runAfter) {
for (const afterPlugin of metadata.runAfter) {
const afterPos = positions.get(afterPlugin);
if (afterPos !== undefined && afterPos >= i) {
errors.push({
type: 'dependency-violation',
plugin: pluginName,
relatedPlugin: afterPlugin,
message: `"${pluginName}" must run AFTER "${afterPlugin}", ` +
`but it appears before it in the pipeline`,
});
}
}
}
// Check conflicts
if (metadata.conflicts) {
for (const conflictPlugin of metadata.conflicts) {
if (positions.has(conflictPlugin)) {
errors.push({
type: 'conflict',
plugin: pluginName,
relatedPlugin: conflictPlugin,
message: `"${pluginName}" conflicts with "${conflictPlugin}" - ` +
`they cannot be used together`,
});
}
}
}
}
// Check for missing required plugins
for (const [name, metadata] of this.registry.entries()) {
if (metadata.required && !positions.has(name)) {
warnings.push({
type: 'missing-required',
plugin: name,
message: `Required plugin "${name}" is missing from the pipeline`,
});
}
}
// Check for circular dependencies
const circularErrors = this.detectCircularDependencies(pluginNames);
errors.push(...circularErrors);
// Validate capabilities
const capabilityErrors = this.validateCapabilities(pluginNames, { debug });
errors.push(...capabilityErrors);
// Log warnings if requested
if (logWarnings && warnings.length > 0) {
for (const warning of warnings) {
logger.warn(warning.message);
}
}
const valid = errors.length === 0;
// Generate suggested order if validation failed
let suggestedOrder;
if (!valid) {
try {
suggestedOrder = this.topologicalSort(pluginNames);
if (debug) {
logger.debug('Suggested order:', suggestedOrder);
}
}
catch (error) {
if (debug) {
logger.error(`Failed to generate suggested order: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
const result = {
valid,
errors,
warnings,
suggestedOrder,
};
// Throw error if validation failed and throwOnError is true
if (!valid && throwOnError) {
const errorMessages = errors.map(e => e.message).join('\n - ');
throw new Error(`Plugin order validation failed:\n - ${errorMessages}${suggestedOrder ? `\n\nSuggested order: ${suggestedOrder.join(' → ')}` : ''}`);
}
return result;
}
/**
* Detect circular dependencies in plugin metadata
*
* @param pluginNames - List of plugin names
* @returns List of errors for circular dependencies found
*/
detectCircularDependencies(pluginNames) {
const errors = [];
const visited = new Set();
const recursionStack = new Set();
const hasCycle = (plugin, path) => {
if (recursionStack.has(plugin)) {
// Found a cycle
const cyclePath = [...path, plugin].join(' → ');
errors.push({
type: 'circular-dependency',
plugin,
message: `Circular dependency detected: ${cyclePath}`,
});
return true;
}
if (visited.has(plugin)) {
return false;
}
visited.add(plugin);
recursionStack.add(plugin);
const metadata = this.registry.get(plugin);
if (metadata) {
// Check runAfter dependencies (these create forward edges)
if (metadata.runAfter) {
for (const dep of metadata.runAfter) {
if (pluginNames.includes(dep)) {
if (hasCycle(dep, [...path, plugin])) {
recursionStack.delete(plugin);
return true;
}
}
}
}
}
recursionStack.delete(plugin);
return false;
};
// Check each plugin for cycles
for (const plugin of pluginNames) {
if (!visited.has(plugin)) {
hasCycle(plugin, []);
}
}
return errors;
}
/**
* Perform topological sort on plugins to find a valid execution order
*
* Uses Kahn's algorithm for topological sorting.
*
* @param pluginNames - List of plugin names to sort
* @returns Sorted list of plugin names
* @throws Error if plugins have circular dependencies
*/
topologicalSort(pluginNames) {
// Build adjacency list and in-degree map
const adjList = new Map();
const inDegree = new Map();
// Initialize
for (const plugin of pluginNames) {
adjList.set(plugin, new Set());
inDegree.set(plugin, 0);
}
// Build graph from metadata
for (const plugin of pluginNames) {
const metadata = this.registry.get(plugin);
if (!metadata)
continue;
// runBefore creates edges: plugin -> beforePlugin
if (metadata.runBefore) {
for (const beforePlugin of metadata.runBefore) {
if (pluginNames.includes(beforePlugin)) {
adjList.get(plugin).add(beforePlugin);
inDegree.set(beforePlugin, inDegree.get(beforePlugin) + 1);
}
}
}
// runAfter creates edges: afterPlugin -> plugin
if (metadata.runAfter) {
for (const afterPlugin of metadata.runAfter) {
if (pluginNames.includes(afterPlugin)) {
adjList.get(afterPlugin).add(plugin);
inDegree.set(plugin, inDegree.get(plugin) + 1);
}
}
}
}
// Kahn's algorithm
const queue = [];
const result = [];
// Find all nodes with in-degree 0
for (const [plugin, degree] of inDegree.entries()) {
if (degree === 0) {
queue.push(plugin);
}
}
while (queue.length > 0) {
const current = queue.shift();
result.push(current);
// Reduce in-degree for neighbors
for (const neighbor of adjList.get(current)) {
const newDegree = inDegree.get(neighbor) - 1;
inDegree.set(neighbor, newDegree);
if (newDegree === 0) {
queue.push(neighbor);
}
}
}
// If result doesn't include all plugins, there's a cycle
if (result.length !== pluginNames.length) {
throw new Error('Cannot generate topological sort: circular dependencies detected');
}
return result;
}
/**
* Get metadata for a plugin
*
* @param pluginName - Name of the plugin
* @returns Plugin metadata or undefined if not found
*/
getMetadata(pluginName) {
return this.registry.get(pluginName);
}
/**
* Add or update metadata for a plugin
*
* @param metadata - Plugin metadata to add/update
*/
registerPlugin(metadata) {
this.registry.set(metadata.name, metadata);
}
/**
* Validate that all required capabilities are provided
*
* Checks that for each plugin's requiresCapabilities, there is at least
* one earlier plugin in the execution order that provides that capability.
*
* @param pluginNames - List of plugin names in execution order
* @param options - Validation options
* @returns Array of capability validation errors
*/
validateCapabilities(pluginNames, options = {}) {
const errors = [];
const providedCapabilities = new Set();
for (const pluginName of pluginNames) {
const metadata = this.registry.get(pluginName);
if (!metadata)
continue;
// Check if this plugin's required capabilities are available
if (metadata.requiresCapabilities) {
for (const requiredCap of metadata.requiresCapabilities) {
if (!providedCapabilities.has(requiredCap)) {
errors.push({
type: 'capability-missing',
plugin: pluginName,
message: `Plugin "${pluginName}" requires capability "${requiredCap}" ` +
`but no earlier plugin provides it`,
});
}
}
}
// Check if this plugin's required phases have completed
if (metadata.requiresPhases && metadata.phase) {
for (const requiredPhase of metadata.requiresPhases) {
if (requiredPhase >= metadata.phase) {
errors.push({
type: 'phase-dependency',
plugin: pluginName,
message: `Plugin "${pluginName}" (Phase ${metadata.phase}) requires ` +
`Phase ${requiredPhase} which runs at the same time or later`,
});
}
}
}
// Add this plugin's capabilities to the available set
if (metadata.capabilities) {
for (const cap of metadata.capabilities) {
providedCapabilities.add(cap);
if (options.debug) {
logger.debug(`Plugin "${pluginName}" provides: ${cap}`);
}
}
}
}
return errors;
}
}
/**
* Create a plugin metadata registry from an array of metadata
*
* @param metadataList - Array of plugin metadata
* @returns Plugin metadata registry
*/
export function createPluginRegistry(metadataList) {
const registry = new Map();
for (const metadata of metadataList) {
registry.set(metadata.name, metadata);
}
return registry;
}
//# sourceMappingURL=plugin-order-validator.js.map