import type { CompiledFragment, RelationshipType } from "./types.js"; import type { ComponentGraph } from "./graph/index.js"; import { ComponentGraphEngine } from "./graph/index.js"; // --- Public types --- export interface CompositionWarning { type: | "missing_parent" | "missing_child" | "missing_composition" | "redundant_alternative" | "deprecated" | "experimental"; component: string; message: string; relatedComponent?: string; } export interface CompositionSuggestion { component: string; reason: string; relationship: RelationshipType | "category_gap"; sourceComponent: string; } export interface CompositionGuideline { component: string; guideline: string; } export interface CompositionAnalysis { /** The validated component names (filtered to those that exist) */ components: string[]; /** Components requested but not found in the registry */ unknown: string[]; /** Issues with the current selection */ warnings: CompositionWarning[]; /** Components to consider adding */ suggestions: CompositionSuggestion[]; /** Relevant usage guidelines for the selected components */ guidelines: CompositionGuideline[]; } // --- Category affinities --- const CATEGORY_AFFINITIES: Record = { forms: ["feedback"], actions: ["feedback"], }; // --- Main function --- /** * Analyzes a set of components as a composition group. * Returns warnings about missing relations, usage conflicts, * and suggestions for additional components. * * When a ComponentGraph is provided via `options.graph`, the analysis is * enhanced with graph-based dependency detection and block-based suggestions. * * Browser-safe: no Node.js APIs used. */ export function analyzeComposition( fragments: Record, componentNames: string[], _context?: string, options?: { graph?: ComponentGraph }, ): CompositionAnalysis { const allNames = new Set(Object.keys(fragments)); // 1. Validate names const components: string[] = []; const unknown: string[] = []; for (const name of componentNames) { if (allNames.has(name)) { components.push(name); } else { unknown.push(name); } } const selectedSet = new Set(components); const warnings: CompositionWarning[] = []; const suggestions: CompositionSuggestion[] = []; const guidelines: CompositionGuideline[] = []; // Track suggestions to avoid duplicates const suggestedSet = new Set(); for (const name of components) { const fragment = fragments[name]; // 2. Relation checks if (fragment.relations) { for (const rel of fragment.relations) { switch (rel.relationship) { case "parent": if (!selectedSet.has(rel.component)) { warnings.push({ type: "missing_parent", component: name, message: `"${name}" expects to be wrapped by "${rel.component}"${rel.note ? `: ${rel.note}` : ""}`, relatedComponent: rel.component, }); } break; case "child": if (!selectedSet.has(rel.component) && !suggestedSet.has(rel.component)) { suggestions.push({ component: rel.component, reason: `"${name}" typically contains "${rel.component}"${rel.note ? `: ${rel.note}` : ""}`, relationship: "child", sourceComponent: name, }); suggestedSet.add(rel.component); } break; case "composition": if (!selectedSet.has(rel.component)) { warnings.push({ type: "missing_composition", component: name, message: `"${name}" is typically used together with "${rel.component}"${rel.note ? `: ${rel.note}` : ""}`, relatedComponent: rel.component, }); } break; case "sibling": if (!selectedSet.has(rel.component) && !suggestedSet.has(rel.component)) { suggestions.push({ component: rel.component, reason: `"${rel.component}" is a sibling of "${name}"${rel.note ? `: ${rel.note}` : ""}`, relationship: "sibling", sourceComponent: name, }); suggestedSet.add(rel.component); } break; case "alternative": if (selectedSet.has(rel.component)) { warnings.push({ type: "redundant_alternative", component: name, message: `"${name}" and "${rel.component}" are alternatives — using both may be redundant${rel.note ? `: ${rel.note}` : ""}`, relatedComponent: rel.component, }); } break; } } } // 3. Usage conflict checks (whenNot) if (fragment.usage?.whenNot) { for (const whenNotEntry of fragment.usage.whenNot) { const lower = whenNotEntry.toLowerCase(); for (const other of components) { if (other !== name && lower.includes(other.toLowerCase())) { guidelines.push({ component: name, guideline: `Potential conflict with "${other}": ${whenNotEntry}`, }); } } } } // 4. Status warnings if (fragment.meta.status === "deprecated") { warnings.push({ type: "deprecated", component: name, message: fragment.meta.description ? `"${name}" is deprecated: ${fragment.meta.description}` : `"${name}" is deprecated`, }); } else if (fragment.meta.status === "experimental") { warnings.push({ type: "experimental", component: name, message: `"${name}" is experimental and may change without notice`, }); } } // 5. Category gap analysis const selectedCategories = new Set( components.map((name) => fragments[name].meta.category) ); for (const [category, affinities] of Object.entries(CATEGORY_AFFINITIES)) { if (!selectedCategories.has(category)) continue; for (const neededCategory of affinities) { if (selectedCategories.has(neededCategory)) continue; // Find the best component from the needed category const candidate = findBestCategoryCandidate( fragments, neededCategory, selectedSet, suggestedSet ); if (candidate) { suggestions.push({ component: candidate, reason: `Compositions using "${category}" components often benefit from a "${neededCategory}" component`, relationship: "category_gap", sourceComponent: components.find( (n) => fragments[n].meta.category === category )!, }); suggestedSet.add(candidate); } } } // 6. Graph-enhanced analysis (when graph data is available) if (options?.graph) { const engine = new ComponentGraphEngine(options.graph); // Add graph-based dependency warnings for (const name of components) { const deps = engine.dependencies(name, ["imports", "hook-depends"]); for (const dep of deps) { if ( !selectedSet.has(dep.target) && !suggestedSet.has(dep.target) && allNames.has(dep.target) ) { suggestions.push({ component: dep.target, reason: `"${name}" ${dep.type === "hook-depends" ? "uses a hook from" : "imports"} "${dep.target}"`, relationship: "composition", sourceComponent: name, }); suggestedSet.add(dep.target); } } } // Add block-based suggestions for (const name of components) { const blocks = engine.blocksUsing(name); for (const blockName of blocks) { // Find other components in this block that aren't selected const blockComps = options.graph.edges .filter( (e) => e.type === "composes" && e.provenance === `block:${blockName}` && (e.source === name || e.target === name) ) .map((e) => (e.source === name ? e.target : e.source)); for (const comp of blockComps) { if ( !selectedSet.has(comp) && !suggestedSet.has(comp) && allNames.has(comp) ) { suggestions.push({ component: comp, reason: `"${name}" and "${comp}" are used together in the "${blockName}" block`, relationship: "composition", sourceComponent: name, }); suggestedSet.add(comp); } } } } } return { components, unknown, warnings, suggestions, guidelines }; } /** * Find the best candidate component from a given category. * Prefers stable components and avoids already-selected or already-suggested ones. */ function findBestCategoryCandidate( fragments: Record, category: string, selectedSet: Set, suggestedSet: Set ): string | null { let best: string | null = null; let bestScore = -1; for (const [name, fragment] of Object.entries(fragments)) { if (fragment.meta.category !== category) continue; if (selectedSet.has(name) || suggestedSet.has(name)) continue; const status = fragment.meta.status ?? "stable"; let score = 0; if (status === "stable") score = 3; else if (status === "beta") score = 2; else if (status === "experimental") score = 1; // deprecated gets 0 if (score > bestScore) { bestScore = score; best = name; } } return best; }