diff --git a/docs/rfcs/component-loading-rewrite/DECISIONS.md b/docs/rfcs/component-loading-rewrite/DECISIONS.md new file mode 100644 index 000000000000..b7b053f9ed2e --- /dev/null +++ b/docs/rfcs/component-loading-rewrite/DECISIONS.md @@ -0,0 +1,312 @@ +# Component Loading Rewrite - Architecture Decision Log + +This document records key architectural decisions made during the rewrite. +Entries are append-only and should not be modified after being added. + +--- + +## ADR-001: Use Strangler Fig Pattern for Migration + +**Date:** 2024-01-XX (update with actual date) + +**Status:** Accepted + +**Context:** +The component loading mechanism is critical infrastructure with extensive e2e test coverage. +A big-bang rewrite carries high risk of regressions that are difficult to debug. + +**Decision:** +Use the Strangler Fig pattern - build V2 loader alongside V1, migrate incrementally by command, +remove V1 only after V2 proves itself. + +**Consequences:** + +- (+) Can ship incrementally with low risk +- (+) Easy rollback at any point +- (+) Can compare V1 vs V2 output during migration +- (-) Temporary code duplication +- (-) Longer total timeline than big-bang (but safer) + +--- + +## ADR-002: Keep Legacy ConsumerComponent Interop + +**Date:** 2024-01-XX + +**Status:** Accepted + +**Context:** +The current loader bridges between legacy `ConsumerComponent` and new `Component` types. +Removing this bridge would be a separate large effort. + +**Decision:** +Keep the ConsumerComponent bridge in V2 loader. Focus the rewrite on the loading pipeline +architecture, not on eliminating legacy types. + +**Consequences:** + +- (+) Smaller scope, more achievable +- (+) Maintains compatibility with code that depends on ConsumerComponent +- (-) Some complexity remains due to dual representation +- (-) Future work needed to fully modernize + +--- + +## ADR-003: Explicit LoadPlan Data Structure + +**Date:** 2024-01-XX + +**Status:** Accepted + +**Context:** +Current loader computes load order inline in `buildLoadGroups()`. This makes it hard to: + +- Debug why components load in a specific order +- Test the ordering logic independently +- Understand the algorithm + +**Decision:** +Introduce an explicit `LoadPlan` data structure that represents the complete load strategy +before execution begins. The plan is: + +- Inspectable (can be logged, debugged) +- Testable (can verify plan without running full pipeline) +- Serializable (can be cached or compared) + +**Consequences:** + +- (+) Much easier to debug loading issues +- (+) Can unit test load ordering logic +- (+) Self-documenting - plan explains itself +- (-) Slight overhead of building plan before execution +- (-) Need to keep plan and execution in sync + +--- + +## ADR-004: Pipeline Phase Architecture + +**Date:** 2024-01-XX + +**Status:** Accepted + +**Context:** +Current loader mixes concerns: discovery, resolution, hydration, enrichment, and execution +are interleaved in complex ways. + +**Decision:** +Separate loading into distinct phases, each with clear input/output contracts: + +1. Discovery - find IDs to load +2. Resolution - build LoadPlan with dependency order +3. Hydration - load raw data from sources +4. Enrichment - add aspects, extensions, env descriptors +5. Assembly - build Component objects +6. Execution - run onComponentLoad slots + +Each phase is implemented in a separate file and can be tested independently. + +**Consequences:** + +- (+) Each phase is understandable in isolation +- (+) Can test phases independently +- (+) Can optimize or replace individual phases +- (+) Clear data flow through pipeline +- (-) Need to define interfaces between phases +- (-) Some operations may feel artificially separated + +--- + +## ADR-005: Feature Flag for V2 Activation + +**Date:** 2024-01-XX + +**Status:** Accepted + +**Context:** +Need a way to test V2 loader before making it the default. + +**Decision:** +Use environment variable `BIT_LOADER_V2=true` to activate V2 loader. +Additionally, `BIT_LOADER_V2_COMPARE=true` runs both loaders and logs differences. + +**Consequences:** + +- (+) Easy to test V2 in any environment +- (+) Can enable for specific CI jobs +- (+) Users can opt-in early +- (-) Need to remember to remove flags after migration + +--- + +## ADR-006: Unified Caching Strategy + +**Date:** 2024-01-XX + +**Status:** Accepted + +**Context:** +The current caching is a major source of bugs and confusion: + +1. **Inconsistent cache keys**: `ComponentLoadOptions` has 12 boolean flags, but only 4 are used + in the cache key (`loadExtensions`, `executeLoadSlot`, `loadDocs`, `loadCompositions`). + The other 8 flags are ignored, leading to incorrect cache hits. + +2. **Inconsistent key computation**: Sometimes the cache key uses the provided loadOptions, + sometimes it's hardcoded to `{ loadExtensions: true, executeLoadSlot: true }`. + +3. **Fallback behavior**: Cache lookup tries the given loadOptions first, then falls back to + hardcoded options - making it very hard to predict what you'll get. + +4. **Multiple overlapping caches**: + + - Components cache (WorkspaceComponentLoader) + - Scope components cache (WorkspaceComponentLoader) + - Extensions cache (WorkspaceComponentLoader) + - ConsumerComponent cache (legacy) + - Dependency cache (filesystem) + - Tree cache (previously Madge) + +5. **No unified invalidation**: These caches can get out of sync with each other. + +**Decision:** +V2 loader will have a single, coherent caching strategy: + +1. **All load options affect cache key** - No silent ignoring of options +2. **Explicit cache key function** - One clear function that computes keys +3. **Single component cache** - One cache for loaded components (can be tiered internally) +4. **Clear invalidation API** - `invalidate(id)`, `invalidateAll()`, with defined semantics +5. **Cache statistics** - Built-in stats for debugging (`cache.getStats()`) + +The cache will be a separate module (`loader-cache.ts`) with its own unit tests. + +**Consequences:** + +- (+) Predictable cache behavior +- (+) Easier to debug cache issues +- (+) Single place to look for caching logic +- (+) Can optimize caching strategy independently +- (-) Need to carefully migrate to avoid performance regression +- (-) Legacy caches (ConsumerComponent, dependency) remain outside V2 scope + +--- + +## ADR-007: Upfront Dependency Resolution (No Runtime Recursion) + +**Date:** 2024-01-XX + +**Status:** Proposed (needs validation - may not be feasible) + +**Context:** +Currently, loading a component can trigger loading of other components at runtime: + +``` +Load component A + → Discovers it needs env B → triggers load of B + → B needs env-of-env C → triggers load of C + → C has extensions D, E → triggers load of D, E... +``` + +This recursive loading is: + +1. **Hard to debug** - no visibility into what triggered what +2. **Hard to predict** - same component may load different things depending on call path +3. **Performance risk** - can cause waterfall of loads instead of batched loads +4. **Breaks mental model** - "load component A" does unpredictable amount of work + +**Ideal Solution (if feasible):** +Resolve ALL dependencies upfront during the Resolution phase: + +1. **Resolution phase discovers everything** - Before any component is hydrated, we know + the complete set of components that will be loaded (envs, env-of-envs, extensions, etc.) + +2. **LoadPlan contains full dependency graph** - The plan explicitly shows what depends on what + +3. **No surprise loads during hydration/execution** - If a component needs another component, + that component is already in the plan and will be loaded in the correct order + +4. **Batched loading** - Since we know everything upfront, we can batch loads efficiently + +**Open Questions:** + +- Can we actually know all dependencies without partially loading components first? +- Some dependencies may only be discoverable after loading (e.g., dynamic env selection) +- The current recursive approach may exist for good reasons we don't fully understand yet + +**Alternative (if full upfront resolution isn't possible):** +At minimum, make the recursive loading **visible and traceable**: + +- Add a "load trace" that shows the chain of what triggered what +- Log when recursive loads happen +- Make it possible to debug without stepping through complex call stacks + +**Consequences (if implemented):** + +- (+) Predictable behavior - "load component A" does exactly what the plan says +- (+) Debuggable - can inspect the LoadPlan to see all dependencies +- (+) Better performance - can batch all loads +- (+) Clearer mental model - Resolution = figure out what, Execution = do it +- (-) Resolution phase may be slower (needs to discover everything upfront) +- (-) May need to handle cycles carefully during resolution +- (-) **May not be possible** - need to validate during Phase 2 implementation + +--- + +## ADR-008: Simplify Legacy ConsumerComponent Creation + +**Date:** 2024-01-XX + +**Status:** Accepted + +**Context:** +Even though we're keeping `ConsumerComponent` (ADR-002), its current creation process is hard to follow: + +1. **Hooks jump into Harmony** - `onConfigLoad`, `loadDependencies`, etc. cause control flow + to jump between legacy code and Harmony aspects unpredictably + +2. **Debugger is useless** - The call stack bounces around so much that stepping through + doesn't help understand what's happening + +3. **Implicit side effects** - Hooks can modify state, load other components, etc. + +**Decision:** +In V2, ConsumerComponent creation will be more linear: + +1. **Gather all data first** - Collect config, files, dependencies before creating ConsumerComponent + +2. **Minimize hooks during creation** - Move hook calls to explicit phases where they're expected + +3. **Make hook calls visible** - If a hook must be called, it should be clear in the code flow + (not buried in a utility function) + +4. **Consider hook batching** - Instead of calling hooks per-component, batch them where possible + +We're NOT removing ConsumerComponent or its hooks, just making the creation flow easier to follow. + +**Consequences:** + +- (+) Easier to debug legacy component creation +- (+) Can step through code linearly +- (+) Side effects are more visible +- (-) May require refactoring how hooks are called +- (-) Need to ensure behavioral compatibility + +--- + +## Template for New Decisions + +``` +## ADR-XXX: Title + +**Date:** YYYY-MM-DD + +**Status:** Proposed | Accepted | Deprecated | Superseded + +**Context:** +What is the issue that we're seeing that is motivating this decision? + +**Decision:** +What is the change that we're proposing? + +**Consequences:** +What becomes easier or more difficult because of this change? +``` diff --git a/docs/rfcs/component-loading-rewrite/README.md b/docs/rfcs/component-loading-rewrite/README.md new file mode 100644 index 000000000000..52773f6678d8 --- /dev/null +++ b/docs/rfcs/component-loading-rewrite/README.md @@ -0,0 +1,317 @@ +# RFC: Component Loading Mechanism Rewrite + +## Status: Planning + +## Problem Statement + +The current component loading mechanism (`WorkspaceComponentLoader`) is: + +1. **Hard to understand** - 1,029 lines with deeply nested logic, especially `buildLoadGroups()` +2. **Hard to modify** - Changes risk regressions despite extensive e2e test coverage +3. **Performance bottleneck** - Complex load ordering and multiple cache layers add overhead +4. **Blocking new features** - Architecture makes certain improvements difficult + +### Complexity Hotspots + +| File | Lines | Issue | +| ------------------------------- | ----- | ------------------------------------------------------------------------ | +| `workspace-component-loader.ts` | 1,029 | Main complexity - `buildLoadGroups()` alone is 120+ lines of dense logic | +| `workspace.ts` | 2,554 | Too many responsibilities | +| `scope.main.runtime.ts` | 1,481 | Similar issues on scope side | + +### Root Causes of Complexity + +1. **Multi-source loading** - Components come from workspace filesystem AND scope storage +2. **Circular bootstrap** - Need envs to load components, but envs ARE components +3. **Legacy interop** - Dual representation (ConsumerComponent + Component) +4. **Inline computation** - Load order computed inline, not inspectable +5. **Mixed concerns** - Discovery, resolution, hydration, execution all interleaved + +### The Caching Nightmare + +The current caching strategy is particularly problematic: + +**ComponentLoadOptions has 12 boolean flags** that affect how a component loads, but: + +- Cache key only uses 4 of them: `loadExtensions`, `executeLoadSlot`, `loadDocs`, `loadCompositions` +- The other 8 flags are **ignored** when computing cache keys +- Sometimes cache key uses the given loadOptions, sometimes it's hardcoded to `{ loadExtensions: true, executeLoadSlot: true }` +- Cache lookup tries given loadOptions first, then falls back to the hardcoded options + +This leads to **unpredictable cache behavior** - it takes significant time just to figure out why unexpected data comes from the cache. + +**Multiple overlapping caches exist:** + +| Cache | Location | Purpose | +| ----------------------- | ------------------------ | ------------------------- | +| Components cache | WorkspaceComponentLoader | Loaded Component objects | +| Scope components cache | WorkspaceComponentLoader | Components from scope | +| Extensions cache | WorkspaceComponentLoader | Component extensions | +| ConsumerComponent cache | Legacy consumer | Legacy component objects | +| Dependency cache | Filesystem | Resolved dependencies | +| Tree cache | (previously Madge) | Dependency tree structure | + +These caches have **no unified invalidation strategy** and can get out of sync. + +### Recursive Loading & Hidden Control Flow + +**Components loading components loading components...** + +When loading a component, it often needs data from other components (e.g., its env). Those components +may themselves need other components. This creates recursive loading that's very hard to track: + +``` +Load component A + → Needs env B + → Load env B + → Needs env-of-env C + → Load C + → Has extensions D, E + → Load D, E... +``` + +There's no visibility into this chain. When something goes wrong, it's unclear which load triggered what. + +**Legacy hooks obscure control flow** + +The legacy `ConsumerComponent` load calls hooks that jump into Harmony: + +- `onConfigLoad` - fires during config parsing +- `loadDependencies` - fires to resolve dependencies +- Other lifecycle hooks + +These hooks mean the call stack jumps between legacy code and Harmony aspects unpredictably. +Even with a debugger, it's hard to follow what's happening. + +**Goal for V2:** Even though we keep ConsumerComponent, we can make its creation more linear +and predictable. The recursive loading should be explicit in the LoadPlan, not discovered at runtime. + +## Goals + +- [ ] Make loading logic understandable in a 5-minute walkthrough +- [ ] Enable safe modifications without fear of regressions +- [ ] Improve loading performance (measurable benchmark) +- [ ] Maintain full backward compatibility +- [ ] Keep legacy ConsumerComponent interop (not in scope to remove) + +## Non-Goals + +- Removing ConsumerComponent bridge (deferred) +- Changing the Component public API +- Modifying how aspects/extensions work fundamentally + +--- + +## New Architecture + +### Core Principle: Explicit Pipeline with Inspectable Plan + +Instead of computing load order inline, we create an explicit `LoadPlan` that can be: + +- Inspected for debugging +- Tested independently +- Optimized without changing the pipeline + +### Pipeline Phases + +``` +┌─────────────────┐ +│ Discovery │ Find all ComponentIDs to load +│ (Input: IDs) │ Output: Set +└────────┬────────┘ + ▼ +┌─────────────────┐ +│ Resolution │ Resolve dependencies, determine load order +│ │ Output: LoadPlan (topologically sorted) +└────────┬────────┘ + ▼ +┌─────────────────┐ +│ Hydration │ Load raw data from sources (workspace/scope) +│ │ Output: Map +└────────┬────────┘ + ▼ +┌─────────────────┐ +│ Enrichment │ Add aspects, extensions, env descriptors +│ │ Output: Map +└────────┬────────┘ + ▼ +┌─────────────────┐ +│ Assembly │ Build Component objects +│ │ Output: Map +└────────┬────────┘ + ▼ +┌─────────────────┐ +│ Execution │ Run onComponentLoad slots +│ │ Output: Map (final) +└─────────────────┘ +``` + +### Key Data Structures + +#### LoadPlan + +```typescript +interface LoadPlan { + // Phases in execution order + phases: LoadPhase[]; + + // Dependency graph for debugging/visualization + dependencies: Map; + + // Flat list in topological order + loadOrder: ComponentID[]; + + // Metadata + stats: { + totalComponents: number; + workspaceComponents: number; + scopeComponents: number; + envCount: number; + }; +} + +interface LoadPhase { + name: string; + type: 'core-envs' | 'env-of-envs' | 'extensions' | 'components'; + ids: ComponentID[]; + source: 'workspace' | 'scope' | 'both'; + + // For debugging + reason: string; // Why this phase exists +} +``` + +#### ComponentSource + +Unified interface for loading from different sources: + +```typescript +interface ComponentSource { + name: string; + priority: number; + + // Check if this source can provide the component + canLoad(id: ComponentID): Promise; + + // Load raw component data (before enrichment) + loadRaw(id: ComponentID): Promise; + + // Batch loading for performance + loadRawMany(ids: ComponentID[]): Promise>; +} + +class WorkspaceSource implements ComponentSource { + name = 'workspace'; + priority = 1; // Higher priority than scope + // ... implementation +} + +class ScopeSource implements ComponentSource { + name = 'scope'; + priority = 2; + // ... implementation +} +``` + +### Caching Strategy + +Single coherent cache with clear keys: + +```typescript +interface LoaderCache { + // Primary cache - fully loaded components + components: Map; + + // Raw data cache - before enrichment (can be shared) + rawData: Map; + + // Plan cache - avoid recomputing load plans + plans: Map; + + // Methods + invalidate(id: ComponentID): void; + invalidateAll(): void; + getStats(): CacheStats; +} +``` + +--- + +## Migration Strategy: Strangler Fig Pattern + +### Why Strangler Fig? + +- Zero big-bang risk +- Can ship incrementally +- Easy rollback at any point +- Old and new code coexist + +### Implementation + +1. **Create `WorkspaceComponentLoaderV2`** alongside existing loader +2. **Feature flag**: `BIT_LOADER_V2=true` environment variable +3. **Parallel execution** during development (run both, compare outputs) +4. **Command-by-command migration**: + - Phase 1: `bit show` (simplest) + - Phase 2: `bit status` (more complex) + - Phase 3: `bit build`, `bit test` (full pipeline) + - Phase 4: All remaining commands +5. **Remove old loader** only after all e2e tests pass with V2 + +### Parallel Execution Mode + +During development, both loaders run and results are compared: + +```typescript +if (process.env.BIT_LOADER_V2_COMPARE) { + const [v1Result, v2Result] = await Promise.all([loaderV1.getMany(ids), loaderV2.getMany(ids)]); + + const diff = compareResults(v1Result, v2Result); + if (diff.length > 0) { + logger.warn('Loader V2 difference detected', { diff }); + } + + return v1Result; // Still use V1 as source of truth +} +``` + +--- + +## File Structure + +``` +scopes/workspace/workspace/workspace-component/ +├── workspace-component-loader.ts # Existing (keep until migration complete) +├── workspace-component-loader-v2.ts # New loader orchestrator +├── load-plan.ts # LoadPlan types and builder +├── component-source.ts # ComponentSource interface +├── loader-cache.ts # Unified caching +└── phases/ + ├── index.ts + ├── discovery.phase.ts + ├── resolution.phase.ts + ├── hydration.phase.ts + ├── enrichment.phase.ts + ├── assembly.phase.ts + └── execution.phase.ts +``` + +--- + +## Success Criteria + +1. **All existing e2e tests pass** with V2 loader +2. **Performance improvement** - Measurable via benchmark (target: 10%+ faster) +3. **Code clarity** - Each phase file < 200 lines, loader orchestrator < 300 lines +4. **Debuggability** - Can inspect LoadPlan to understand why components load in specific order +5. **Test coverage** - Each phase has unit tests independent of full pipeline + +--- + +## References + +- Current loader: `scopes/workspace/workspace/workspace-component/workspace-component-loader.ts` +- Component factory: `scopes/component/component/component-factory.ts` +- Scope loader: `scopes/scope/scope/scope-component-loader.ts` +- Task tracking: [TASKS.md](./TASKS.md) +- Decision log: [DECISIONS.md](./DECISIONS.md) diff --git a/docs/rfcs/component-loading-rewrite/TASKS.md b/docs/rfcs/component-loading-rewrite/TASKS.md new file mode 100644 index 000000000000..0b7c9bde344b --- /dev/null +++ b/docs/rfcs/component-loading-rewrite/TASKS.md @@ -0,0 +1,166 @@ +# Component Loading Rewrite - Task Tracking + +**Legend:** + +- `[ ]` - Not started +- `[~]` - In progress +- `[x]` - Completed +- `[!]` - Blocked + +--- + +## Phase 1: Safety Net + +> Establish testing infrastructure before any code changes + +### Contract Tests + +- [x] Identify key loading scenarios from existing e2e tests +- [x] Create `component-loader-contract.e2e.ts` with scenarios: + - [x] Load workspace-only component + - [x] Load scope-only component + - [x] Load component with workspace + scope data (merged) + - [x] Load component that's out-of-sync + - [x] Load new component (not in scope yet) + - [x] Load component with extensions + - [x] Load component with env + - [ ] Load env-of-env scenario (deferred - complex setup) + - [x] Load multiple components with shared dependencies + - [x] Verify caching behavior (same component loaded twice) +- [x] Ensure contract tests pass with current loader (21 tests passing) + +### Instrumentation + +- [ ] Add structured logging to current loader (optional, for debugging) +- [ ] Create load-trace comparison utility + +--- + +## Phase 2: New Architecture + +> Build V2 loader alongside existing code + +### 2.1 Core Types & Interfaces + +- [x] Create `load-plan.ts` with LoadPlan, LoadPhase types +- [x] Create `component-source.ts` with ComponentSource interface +- [x] Create `loader-cache.ts` with unified cache implementation +- [ ] Add unit tests for each new module + +### 2.2 Pipeline Phases + +- [x] Create `phases/` directory structure +- [x] Implement `discovery.phase.ts` + - [ ] Unit tests +- [x] Implement `resolution.phase.ts` (build LoadPlan) + - [ ] Unit tests +- [x] Implement `hydration.phase.ts` (load raw data) + - [ ] Unit tests +- [x] Implement `enrichment.phase.ts` (add aspects, env descriptors) + - [ ] Unit tests +- [x] Implement `assembly.phase.ts` (build Component objects) + - [ ] Unit tests +- [x] Implement `execution.phase.ts` (run slots) + - [ ] Unit tests + +### 2.3 Component Sources + +- [ ] Implement `WorkspaceSource` + - [ ] Unit tests +- [ ] Implement `ScopeSource` + - [ ] Unit tests + +### 2.4 Loader V2 Orchestrator + +- [ ] Create `workspace-component-loader-v2.ts` +- [ ] Wire up all phases +- [ ] Add feature flag support (`BIT_LOADER_V2`) +- [ ] Integration tests with mock sources + +--- + +## Phase 3: Migration + +> Gradually switch commands to V2 loader + +### 3.1 Infrastructure + +- [ ] Add parallel execution mode (`BIT_LOADER_V2_COMPARE`) +- [ ] Create result comparison utility +- [ ] Add logging for discrepancies + +### 3.2 Command Migration (in order of complexity) + +#### `bit show` + +- [ ] Enable V2 for `bit show` command +- [ ] Run comparison mode, fix discrepancies +- [ ] Run relevant e2e tests +- [ ] Mark as V2-ready + +#### `bit list` + +- [ ] Enable V2 for `bit list` command +- [ ] Run comparison mode, fix discrepancies +- [ ] Run relevant e2e tests +- [ ] Mark as V2-ready + +#### `bit status` + +- [ ] Enable V2 for `bit status` command +- [ ] Run comparison mode, fix discrepancies +- [ ] Run relevant e2e tests +- [ ] Mark as V2-ready + +#### `bit build` + +- [ ] Enable V2 for `bit build` command +- [ ] Run comparison mode, fix discrepancies +- [ ] Run relevant e2e tests +- [ ] Mark as V2-ready + +#### `bit test` + +- [ ] Enable V2 for `bit test` command +- [ ] Run comparison mode, fix discrepancies +- [ ] Run relevant e2e tests +- [ ] Mark as V2-ready + +#### Remaining Commands + +- [ ] Identify all commands using component loader +- [ ] Migrate each remaining command +- [ ] Full e2e test suite pass + +--- + +## Phase 4: Consolidation + +> Clean up after successful migration + +### Cleanup + +- [ ] Remove `workspace-component-loader.ts` (old loader) +- [ ] Remove feature flags +- [ ] Remove parallel execution / comparison code +- [ ] Update documentation + +### Validation + +- [ ] Full e2e test suite pass +- [ ] Performance benchmark comparison (before/after) +- [ ] Code review of final architecture + +--- + +## Ongoing Notes + +_Add notes here as work progresses_ + +### Blockers + +_(none yet)_ + +### Discoveries + +_(add findings during implementation)_ diff --git a/e2e/harmony/component-loader-contract.e2e.ts b/e2e/harmony/component-loader-contract.e2e.ts new file mode 100644 index 000000000000..b04ca4903f7c --- /dev/null +++ b/e2e/harmony/component-loader-contract.e2e.ts @@ -0,0 +1,273 @@ +/** + * Component Loader Contract Tests + * + * These tests define the expected behaviors of the component loading mechanism. + * They serve as a safety net for the V2 loader rewrite - any new implementation + * must pass these tests to ensure behavioral compatibility. + * + * The tests focus on OBSERVABLE OUTCOMES, not implementation details. + */ +import { expect } from 'chai'; +import { Helper } from '@teambit/legacy.e2e-helper'; + +describe('component loader contract tests', function () { + this.timeout(0); + let helper: Helper; + + before(() => { + helper = new Helper(); + }); + + after(() => { + helper.scopeHelper.destroy(); + }); + + describe('loading workspace-only components', () => { + before(() => { + helper.scopeHelper.reInitWorkspace(); + helper.fixtures.populateComponents(2); + }); + + it('should load a new component that exists only in workspace (not tagged)', () => { + const show = helper.command.showComponent('comp1'); + expect(show).to.include('comp1'); + }); + + it('should list all workspace components', () => { + const list = helper.command.listParsed(); + expect(list).to.have.lengthOf(2); + }); + + it('should show component status as new', () => { + const status = helper.command.statusJson(); + expect(status.newComponents).to.have.lengthOf(2); + }); + }); + + describe('loading scope-only components', () => { + before(() => { + helper.scopeHelper.setWorkspaceWithRemoteScope(); + helper.fixtures.populateComponents(1); + helper.command.tagAllWithoutBuild(); + helper.command.export(); + // Re-init workspace without the component files + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(); + }); + + it('should show remote component without importing', () => { + const show = helper.command.showComponent(`${helper.scopes.remote}/comp1 --remote`); + expect(show).to.include('comp1'); + }); + + it('should not import objects when just showing', () => { + const objects = helper.command.catScope(); + expect(objects).to.have.lengthOf(0); + }); + }); + + describe('loading components with workspace + scope data (merged)', () => { + before(() => { + helper.scopeHelper.setWorkspaceWithRemoteScope(); + helper.fixtures.populateComponents(1); + helper.command.tagAllWithoutBuild(); + helper.command.export(); + // Modify the component locally + helper.fs.appendFile('comp1/index.js', '\n// modified'); + }); + + it('should show component as modified', () => { + const status = helper.command.statusJson(); + expect(status.modifiedComponents).to.have.lengthOf(1); + }); + + it('should load component with local modifications merged with scope data', () => { + const show = helper.command.showComponent('comp1'); + // Component should have version from scope but show as modified + expect(show).to.include('comp1'); + }); + }); + + describe('loading out-of-sync components', () => { + describe('bitmap has no version but scope has tagged version', () => { + let scopeOutOfSync: string; + + before(() => { + helper.scopeHelper.setWorkspaceWithRemoteScope(); + helper.fixtures.createComponentBarFoo(); + helper.fixtures.addComponentBarFoo(); + const bitMap = helper.bitMap.read(); + helper.fixtures.tagComponentBarFoo(); + // Revert bitmap to pre-tag state (simulating out-of-sync) + helper.bitMap.write(bitMap); + scopeOutOfSync = helper.scopeHelper.cloneWorkspace(); + }); + + it('should sync bitmap to match scope on status', () => { + helper.scopeHelper.getClonedWorkspace(scopeOutOfSync); + helper.command.status(); + const bitMap = helper.bitMap.read(); + expect(bitMap['bar/foo'].version).to.equal('0.0.1'); + }); + }); + + describe('bitmap shows exported but scope shows only tagged', () => { + let scopeOutOfSync: string; + + before(() => { + helper.scopeHelper.setWorkspaceWithRemoteScope(); + helper.fixtures.createComponentBarFoo(); + helper.fixtures.addComponentBarFoo(); + helper.fixtures.tagComponentBarFoo(); + const bitMapBeforeExport = helper.bitMap.read(); + helper.command.export(); + // Revert bitmap to pre-export state + helper.bitMap.write(bitMapBeforeExport); + scopeOutOfSync = helper.scopeHelper.cloneWorkspace(); + }); + + it('should sync bitmap to match scope (exported state)', () => { + helper.scopeHelper.getClonedWorkspace(scopeOutOfSync); + helper.command.status(); + const bitMap = helper.bitMap.read(); + expect(bitMap['bar/foo'].scope).to.equal(helper.scopes.remote); + }); + }); + }); + + describe('loading components with extensions', () => { + before(() => { + helper.scopeHelper.setWorkspaceWithRemoteScope(); + helper.fixtures.populateComponents(1); + }); + + it('should load component with its configured extensions', () => { + const show = helper.command.showComponent('comp1'); + // All components have at least the env extension - check for "aspects" in output + expect(show).to.include('aspects'); + }); + }); + + describe('loading components with custom env', () => { + before(() => { + helper.scopeHelper.setWorkspaceWithRemoteScope(); + helper.fixtures.populateComponents(1); + helper.command.setEnv('comp1', 'teambit.harmony/aspect'); + }); + + it('should load component with the correct env', () => { + const envId = helper.env.getComponentEnv('comp1'); + expect(envId).to.include('teambit.harmony/aspect'); + }); + + it('should show the env in component details', () => { + const show = helper.command.showComponent('comp1'); + expect(show).to.include('env'); + }); + }); + + describe('loading multiple components with shared dependencies', () => { + before(() => { + helper.scopeHelper.setWorkspaceWithRemoteScope(); + // Create comp2 first + helper.fs.outputFile('comp2/index.js', 'module.exports = {}'); + helper.command.addComponent('comp2'); + // Create comp1 that depends on comp2 using the correct scope name + helper.fs.outputFile('comp1/index.js', `const comp2 = require('@${helper.scopes.remote}/comp2');`); + helper.command.addComponent('comp1'); + }); + + it('should load both components correctly', () => { + const list = helper.command.listParsed(); + expect(list).to.have.lengthOf(2); + }); + + it('should detect dependency relationship', () => { + const deps = helper.command.getCompDepsIdsFromData('comp1'); + const hasComp2Dep = deps.some((depId: string) => depId.includes('comp2')); + expect(hasComp2Dep).to.be.true; + }); + }); + + describe('caching behavior', () => { + before(() => { + helper.scopeHelper.reInitWorkspace(); + helper.fixtures.populateComponents(1); + }); + + it('should return same component data when loaded twice', () => { + const show1 = helper.command.showComponent('comp1'); + const show2 = helper.command.showComponent('comp1'); + expect(show1).to.equal(show2); + }); + + it('should reflect file changes after modification', () => { + const statusBefore = helper.command.statusJson(); + expect(statusBefore.newComponents).to.have.lengthOf(1); + + helper.fs.appendFile('comp1/index.js', '\n// modified'); + + // Status should still show component (cache should not prevent seeing changes) + const statusAfter = helper.command.statusJson(); + expect(statusAfter.newComponents).to.have.lengthOf(1); + }); + }); + + describe('error handling', () => { + before(() => { + helper.scopeHelper.reInitWorkspace(); + }); + + it('should throw meaningful error for non-existent component', () => { + let error: Error | null = null; + try { + helper.command.showComponent('non-existent-component'); + } catch (e: any) { + error = e; + } + expect(error).to.not.be.null; + }); + }); + + describe('loading tagged but not exported components', () => { + before(() => { + helper.scopeHelper.setWorkspaceWithRemoteScope(); + helper.fixtures.populateComponents(1); + helper.command.tagAllWithoutBuild(); + }); + + it('should show component as staged', () => { + const status = helper.command.statusJson(); + expect(status.stagedComponents).to.have.lengthOf(1); + }); + + it('should load component with version from tag', () => { + const show = helper.command.showComponent('comp1'); + expect(show).to.include('0.0.1'); + }); + }); + + describe('loading components after import', () => { + before(() => { + helper.scopeHelper.setWorkspaceWithRemoteScope(); + helper.fixtures.populateComponents(1); + helper.command.tagAllWithoutBuild(); + helper.command.export(); + + // Create a new workspace and import the component + helper.scopeHelper.reInitWorkspace(); + helper.scopeHelper.addRemoteScope(); + helper.command.importComponent('comp1'); + }); + + it('should load imported component', () => { + const list = helper.command.listParsed(); + expect(list).to.have.lengthOf(1); + }); + + it('should show component as not modified', () => { + const status = helper.command.statusJson(); + expect(status.modifiedComponents).to.have.lengthOf(0); + }); + }); +}); diff --git a/scopes/workspace/workspace/workspace-component/loader-v2/component-source.ts b/scopes/workspace/workspace/workspace-component/loader-v2/component-source.ts new file mode 100644 index 000000000000..153ad33e1d3d --- /dev/null +++ b/scopes/workspace/workspace/workspace-component/loader-v2/component-source.ts @@ -0,0 +1,177 @@ +import type { ComponentID } from '@teambit/component-id'; +import type { ExtensionDataList } from '@teambit/legacy.extension-data'; +import type { ConsumerComponent } from '@teambit/legacy.consumer-component'; + +/** + * Raw component data before enrichment. + * This is what a ComponentSource returns - the basic data needed + * to construct a Component. + */ +export interface RawComponentData { + /** The component ID (may differ from requested if out-of-sync) */ + id: ComponentID; + + /** Legacy ConsumerComponent (maintained for compatibility) */ + consumerComponent: ConsumerComponent; + + /** Extensions configured for this component */ + extensions: ExtensionDataList; + + /** Errors encountered during loading */ + errors?: Error[]; + + /** Whether this component exists only in workspace (not tagged/exported) */ + isNew: boolean; + + /** The source that provided this data */ + source: 'workspace' | 'scope'; +} + +/** + * ComponentSource is the abstraction for loading raw component data. + * + * Both workspace and scope implement this interface, allowing the loader + * to treat them uniformly while each handles its own storage mechanism. + */ +export interface ComponentSource { + /** Name of this source for logging */ + readonly name: string; + + /** + * Priority for source selection (lower = higher priority). + * When a component exists in multiple sources, the one with + * lower priority number is preferred. + */ + readonly priority: number; + + /** + * Check if this source can provide a component. + * Returns true if the component exists in this source. + */ + has(id: ComponentID): Promise; + + /** + * Check multiple components at once (for performance). + * Returns a map of ID string -> boolean. + */ + hasMany(ids: ComponentID[]): Promise>; + + /** + * Load raw component data for a single component. + * Throws if the component doesn't exist. + */ + loadRaw(id: ComponentID): Promise; + + /** + * Load raw component data for multiple components. + * Returns a map of ID string -> RawComponentData. + * Missing components are not included in the result. + */ + loadRawMany(ids: ComponentID[]): Promise>; + + /** + * Get the extensions for a component without fully loading it. + * Used during plan building to discover dependencies. + */ + getExtensions(id: ComponentID): Promise; + + /** + * Get extensions for multiple components at once. + */ + getExtensionsMany(ids: ComponentID[]): Promise>; +} + +/** + * Result of loading from multiple sources + */ +export interface MultiSourceLoadResult { + /** Successfully loaded components */ + loaded: Map; + + /** Components that failed to load */ + failed: Map; + + /** Components that weren't found in any source */ + notFound: ComponentID[]; +} + +/** + * Coordinates loading from multiple ComponentSources. + * Tries sources in priority order until the component is found. + */ +export class MultiSourceLoader { + constructor(private sources: ComponentSource[]) { + // Sort by priority (lower number = higher priority) + this.sources.sort((a, b) => a.priority - b.priority); + } + + /** + * Load a component from the first source that has it. + */ + async load(id: ComponentID): Promise { + for (const source of this.sources) { + const has = await source.has(id); + if (has) { + return source.loadRaw(id); + } + } + return null; + } + + /** + * Load multiple components, trying each source for each component. + */ + async loadMany(ids: ComponentID[]): Promise { + const result: MultiSourceLoadResult = { + loaded: new Map(), + failed: new Map(), + notFound: [], + }; + + // Track which IDs still need to be loaded + let remaining = [...ids]; + + for (const source of this.sources) { + if (remaining.length === 0) break; + + // Check which remaining IDs this source has + const hasMap = await source.hasMany(remaining); + const idsInSource = remaining.filter((id) => hasMap.get(id.toString()) === true); + + if (idsInSource.length > 0) { + try { + const loaded = await source.loadRawMany(idsInSource); + for (const [idStr, data] of loaded) { + result.loaded.set(idStr, data); + } + } catch { + // If batch load fails, try individually + for (const id of idsInSource) { + try { + const data = await source.loadRaw(id); + result.loaded.set(id.toString(), data); + } catch (err: any) { + result.failed.set(id.toString(), err); + } + } + } + + // Remove loaded/failed IDs from remaining + const loadedOrFailed = new Set([...result.loaded.keys(), ...result.failed.keys()]); + remaining = remaining.filter((id) => !loadedOrFailed.has(id.toString())); + } + } + + // Any remaining IDs weren't found in any source + result.notFound = remaining; + + return result; + } + + /** + * Get sources sorted by priority + */ + getSources(): ComponentSource[] { + return [...this.sources]; + } +} diff --git a/scopes/workspace/workspace/workspace-component/loader-v2/index.ts b/scopes/workspace/workspace/workspace-component/loader-v2/index.ts new file mode 100644 index 000000000000..ef6137706253 --- /dev/null +++ b/scopes/workspace/workspace/workspace-component/loader-v2/index.ts @@ -0,0 +1,26 @@ +/** + * Component Loader V2 + * + * A rewritten component loading mechanism with: + * - Explicit LoadPlan for inspectable load ordering + * - Unified caching strategy + * - Clear pipeline phases + * + * See docs/rfcs/component-loading-rewrite/ for the full RFC. + */ + +// Core types +export { + LoadPlan, + LoadPhase, + LoadPlanOptions, + LoadPlanStats, + createEmptyPlan, + createPhase, + formatPlan, + validatePlan, +} from './load-plan'; + +export { ComponentSource, RawComponentData, MultiSourceLoader, MultiSourceLoadResult } from './component-source'; + +export { LoaderCache, CacheStats, CacheKeyOptions, createLoaderCache } from './loader-cache'; diff --git a/scopes/workspace/workspace/workspace-component/loader-v2/load-plan.ts b/scopes/workspace/workspace/workspace-component/loader-v2/load-plan.ts new file mode 100644 index 000000000000..693f080d56d4 --- /dev/null +++ b/scopes/workspace/workspace/workspace-component/loader-v2/load-plan.ts @@ -0,0 +1,191 @@ +import type { ComponentID } from '@teambit/component-id'; + +/** + * Represents a single phase in the component loading pipeline. + * Components are grouped into phases based on their role (env, extension, regular component) + * and must be loaded in the correct order to satisfy dependencies. + */ +export interface LoadPhase { + /** Human-readable name for debugging */ + name: string; + + /** Type of components in this phase */ + type: 'core-envs' | 'env-of-envs' | 'envs' | 'extensions' | 'components'; + + /** Component IDs to load from workspace */ + workspaceIds: ComponentID[]; + + /** Component IDs to load from scope */ + scopeIds: ComponentID[]; + + /** Whether to load these components as aspects */ + loadAsAspects: boolean; + + /** Explanation of why this phase exists (for debugging) */ + reason: string; +} + +/** + * LoadPlan represents the complete strategy for loading a set of components. + * + * Key principles: + * 1. INSPECTABLE - The plan can be logged and debugged before execution + * 2. DETERMINISTIC - Same inputs produce same plan + * 3. EXPLICIT - No hidden recursive loads; everything is in the plan + * + * The plan is built during the Resolution phase and executed during subsequent phases. + */ +export interface LoadPlan { + /** Ordered list of phases to execute */ + phases: LoadPhase[]; + + /** + * Dependency graph for debugging/visualization. + * Maps component ID string to its dependency ID strings. + */ + dependencyGraph: Map; + + /** + * All component IDs in topological order (dependencies before dependents). + * This is the flattened version of phases for simple iteration. + */ + loadOrder: ComponentID[]; + + /** Original IDs that were requested to be loaded */ + requestedIds: ComponentID[]; + + /** Statistics about the plan */ + stats: LoadPlanStats; + + /** Timestamp when the plan was created */ + createdAt: number; +} + +export interface LoadPlanStats { + totalComponents: number; + workspaceComponents: number; + scopeComponents: number; + phaseCount: number; + envCount: number; + extensionCount: number; +} + +/** + * Options for building a LoadPlan + */ +export interface LoadPlanOptions { + /** Whether to load extensions for components */ + loadExtensions?: boolean; + + /** Whether to execute load slots after loading */ + executeLoadSlot?: boolean; + + /** Whether to load seeders (envs/aspects that components depend on) */ + loadSeedersAsAspects?: boolean; + + /** Component IDs to skip loading as aspects */ + idsToNotLoadAsAspects?: string[]; +} + +/** + * Creates an empty LoadPlan + */ +export function createEmptyPlan(requestedIds: ComponentID[]): LoadPlan { + return { + phases: [], + dependencyGraph: new Map(), + loadOrder: [], + requestedIds, + stats: { + totalComponents: 0, + workspaceComponents: 0, + scopeComponents: 0, + phaseCount: 0, + envCount: 0, + extensionCount: 0, + }, + createdAt: Date.now(), + }; +} + +/** + * Creates a new LoadPhase + */ +export function createPhase( + name: string, + type: LoadPhase['type'], + reason: string, + options: { + workspaceIds?: ComponentID[]; + scopeIds?: ComponentID[]; + loadAsAspects?: boolean; + } = {} +): LoadPhase { + return { + name, + type, + workspaceIds: options.workspaceIds || [], + scopeIds: options.scopeIds || [], + loadAsAspects: options.loadAsAspects ?? false, + reason, + }; +} + +/** + * Formats a LoadPlan for logging/debugging + */ +export function formatPlan(plan: LoadPlan): string { + const lines: string[] = [ + `=== LoadPlan ===`, + `Requested: ${plan.requestedIds.map((id) => id.toString()).join(', ')}`, + `Total components: ${plan.stats.totalComponents}`, + ` - Workspace: ${plan.stats.workspaceComponents}`, + ` - Scope: ${plan.stats.scopeComponents}`, + ` - Envs: ${plan.stats.envCount}`, + ` - Extensions: ${plan.stats.extensionCount}`, + ``, + `Phases (${plan.phases.length}):`, + ]; + + plan.phases.forEach((phase, index) => { + const wsCount = phase.workspaceIds.length; + const scopeCount = phase.scopeIds.length; + lines.push(` ${index + 1}. ${phase.name} (${phase.type})`); + lines.push(` Reason: ${phase.reason}`); + lines.push(` Workspace IDs: ${wsCount} | Scope IDs: ${scopeCount}`); + lines.push(` Load as aspects: ${phase.loadAsAspects}`); + if (wsCount > 0 && wsCount <= 5) { + lines.push(` WS: ${phase.workspaceIds.map((id) => id.toString()).join(', ')}`); + } + if (scopeCount > 0 && scopeCount <= 5) { + lines.push(` Scope: ${phase.scopeIds.map((id) => id.toString()).join(', ')}`); + } + }); + + return lines.join('\n'); +} + +/** + * Validates a LoadPlan for common issues + */ +export function validatePlan(plan: LoadPlan): string[] { + const errors: string[] = []; + + if (plan.phases.length === 0 && plan.requestedIds.length > 0) { + errors.push('Plan has no phases but has requested IDs'); + } + + // Check for duplicate IDs across phases + const seenIds = new Set(); + for (const phase of plan.phases) { + for (const id of [...phase.workspaceIds, ...phase.scopeIds]) { + const idStr = id.toString(); + if (seenIds.has(idStr)) { + errors.push(`Duplicate ID in plan: ${idStr}`); + } + seenIds.add(idStr); + } + } + + return errors; +} diff --git a/scopes/workspace/workspace/workspace-component/loader-v2/loader-cache.ts b/scopes/workspace/workspace/workspace-component/loader-v2/loader-cache.ts new file mode 100644 index 000000000000..40c376ef039e --- /dev/null +++ b/scopes/workspace/workspace/workspace-component/loader-v2/loader-cache.ts @@ -0,0 +1,262 @@ +import type { Component } from '@teambit/component'; +import type { ComponentID } from '@teambit/component-id'; +import type { InMemoryCache } from '@teambit/harmony.modules.in-memory-cache'; +import { createInMemoryCache, getMaxSizeForComponents } from '@teambit/harmony.modules.in-memory-cache'; +import type { RawComponentData } from './component-source'; +import type { LoadPlan, LoadPlanOptions } from './load-plan'; + +/** + * Cache statistics for debugging and monitoring + */ +export interface CacheStats { + componentHits: number; + componentMisses: number; + rawDataHits: number; + rawDataMisses: number; + planHits: number; + planMisses: number; + size: number; + invalidations: number; +} + +/** + * Options that affect cache key computation. + * These are the options that, when different, should result in different cache entries. + */ +export interface CacheKeyOptions { + loadExtensions?: boolean; + executeLoadSlot?: boolean; + loadDocs?: boolean; + loadCompositions?: boolean; + loadSeedersAsAspects?: boolean; +} + +/** + * LoaderCache provides a unified caching strategy for the V2 component loader. + * + * Key principles: + * 1. EXPLICIT KEYS - All options that affect loading are included in the cache key + * 2. SINGLE CACHE - One coherent cache instead of multiple overlapping caches + * 3. CLEAR INVALIDATION - Simple invalidation API with defined semantics + * 4. OBSERVABLE - Built-in stats for debugging + * + * This replaces the complex multi-cache system in V1 loader. + */ +export class LoaderCache { + /** Cache for fully loaded Component objects */ + private components: InMemoryCache; + + /** Cache for raw component data (before enrichment) */ + private rawData: InMemoryCache; + + /** Cache for load plans */ + private plans: InMemoryCache; + + /** Statistics */ + private stats: CacheStats = { + componentHits: 0, + componentMisses: 0, + rawDataHits: 0, + rawDataMisses: 0, + planHits: 0, + planMisses: 0, + size: 0, + invalidations: 0, + }; + + constructor() { + const maxSize = getMaxSizeForComponents(); + this.components = createInMemoryCache({ maxSize }); + this.rawData = createInMemoryCache({ maxSize }); + this.plans = createInMemoryCache({ maxSize: 100 }); // Plans are larger, cache fewer + } + + // ============ Component Cache ============ + + /** + * Get a component from cache. + * Returns undefined if not cached with the given options. + */ + getComponent(id: ComponentID, options: CacheKeyOptions = {}): Component | undefined { + const key = this.computeComponentKey(id, options); + const cached = this.components.get(key); + if (cached) { + this.stats.componentHits++; + } else { + this.stats.componentMisses++; + } + return cached; + } + + /** + * Store a component in cache. + */ + setComponent(id: ComponentID, component: Component, options: CacheKeyOptions = {}): void { + const key = this.computeComponentKey(id, options); + this.components.set(key, component); + this.stats.size++; + } + + /** + * Check if a component is cached. + */ + hasComponent(id: ComponentID, options: CacheKeyOptions = {}): boolean { + const key = this.computeComponentKey(id, options); + return this.components.has(key); + } + + // ============ Raw Data Cache ============ + + /** + * Get raw component data from cache. + */ + getRawData(id: ComponentID): RawComponentData | undefined { + const key = id.toString(); + const cached = this.rawData.get(key); + if (cached) { + this.stats.rawDataHits++; + } else { + this.stats.rawDataMisses++; + } + return cached; + } + + /** + * Store raw component data in cache. + */ + setRawData(id: ComponentID, data: RawComponentData): void { + const key = id.toString(); + this.rawData.set(key, data); + } + + /** + * Check if raw data is cached. + */ + hasRawData(id: ComponentID): boolean { + const key = id.toString(); + return this.rawData.has(key); + } + + // ============ Plan Cache ============ + + /** + * Get a cached load plan. + * Plans are keyed by the sorted list of requested IDs and options. + */ + getPlan(ids: ComponentID[], options: LoadPlanOptions = {}): LoadPlan | undefined { + const key = this.computePlanKey(ids, options); + const cached = this.plans.get(key); + if (cached) { + this.stats.planHits++; + } else { + this.stats.planMisses++; + } + return cached; + } + + /** + * Store a load plan in cache. + */ + setPlan(ids: ComponentID[], plan: LoadPlan, options: LoadPlanOptions = {}): void { + const key = this.computePlanKey(ids, options); + this.plans.set(key, plan); + } + + // ============ Invalidation ============ + + /** + * Invalidate all cached data for a specific component. + */ + invalidate(id: ComponentID): void { + const idStr = id.toString(); + + // Invalidate component entries (need to check all option combinations) + // This is a limitation - we can't efficiently invalidate all keys for an ID + // For now, we'll just delete the raw data and let components be re-fetched + this.rawData.delete(idStr); + + // Clear all plans (they may reference this component) + this.plans.deleteAll(); + + this.stats.invalidations++; + } + + /** + * Invalidate all cached data. + */ + invalidateAll(): void { + this.components.deleteAll(); + this.rawData.deleteAll(); + this.plans.deleteAll(); + this.stats.invalidations++; + this.stats.size = 0; + } + + // ============ Stats ============ + + /** + * Get cache statistics. + */ + getStats(): CacheStats { + return { ...this.stats }; + } + + /** + * Reset statistics (useful for testing/debugging). + */ + resetStats(): void { + this.stats = { + componentHits: 0, + componentMisses: 0, + rawDataHits: 0, + rawDataMisses: 0, + planHits: 0, + planMisses: 0, + size: 0, + invalidations: 0, + }; + } + + // ============ Key Computation ============ + + /** + * Compute a cache key for a component. + * ALL options that affect loading are included in the key. + */ + private computeComponentKey(id: ComponentID, options: CacheKeyOptions): string { + // Sort options for consistent key generation + const optParts: string[] = []; + if (options.loadExtensions) optParts.push('ext'); + if (options.executeLoadSlot) optParts.push('slot'); + if (options.loadDocs) optParts.push('docs'); + if (options.loadCompositions) optParts.push('comp'); + if (options.loadSeedersAsAspects) optParts.push('seed'); + + const optStr = optParts.length > 0 ? `:${optParts.join(',')}` : ''; + return `${id.toString()}${optStr}`; + } + + /** + * Compute a cache key for a load plan. + */ + private computePlanKey(ids: ComponentID[], options: LoadPlanOptions): string { + // Sort IDs for consistent key + const sortedIds = [...ids].sort((a, b) => a.toString().localeCompare(b.toString())); + const idsStr = sortedIds.map((id) => id.toString()).join(','); + + const optParts: string[] = []; + if (options.loadExtensions) optParts.push('ext'); + if (options.executeLoadSlot) optParts.push('slot'); + if (options.loadSeedersAsAspects) optParts.push('seed'); + + const optStr = optParts.length > 0 ? `:${optParts.join(',')}` : ''; + return `plan:${idsStr}${optStr}`; + } +} + +/** + * Create a new LoaderCache instance. + */ +export function createLoaderCache(): LoaderCache { + return new LoaderCache(); +} diff --git a/scopes/workspace/workspace/workspace-component/loader-v2/phases/assembly.phase.ts b/scopes/workspace/workspace/workspace-component/loader-v2/phases/assembly.phase.ts new file mode 100644 index 000000000000..70b521a63518 --- /dev/null +++ b/scopes/workspace/workspace/workspace-component/loader-v2/phases/assembly.phase.ts @@ -0,0 +1,123 @@ +import type { Component } from '@teambit/component'; +import { ComponentFS, Config, State } from '@teambit/component'; +import type { ComponentID } from '@teambit/component-id'; +import { EnvsAspect } from '@teambit/envs'; +import { DependencyResolverAspect } from '@teambit/dependency-resolver'; +import type { EnrichedComponentData } from './enrichment.phase'; + +/** + * Result of the Assembly phase + */ +export interface AssemblyResult { + /** Successfully assembled Component objects */ + components: Map; + + /** Components that failed assembly */ + failed: Map; +} + +/** + * Assembly Phase + * + * Purpose: Build Component objects from enriched data. + * + * Input: Map of EnrichedComponentData + * Output: Map of Component objects ready for execution + * + * This phase: + * 1. Creates Component instances with State, Config, TagMap + * 2. Sets up ComponentFS + * 3. Upserts env and dependency resolver data into extensions + * 4. Prepares components for the execution phase + */ +export class AssemblyPhase { + constructor( + private createComponent: (id: ComponentID, state: State) => Component, + private createAspectList: (extensions: any) => Promise + ) {} + + /** + * Assemble Component objects from enriched data. + */ + async execute(enriched: Map): Promise { + const result: AssemblyResult = { + components: new Map(), + failed: new Map(), + }; + + for (const [idStr, data] of enriched) { + try { + const component = await this.assembleComponent(data); + result.components.set(idStr, component); + } catch (err: any) { + result.failed.set(idStr, err); + } + } + + return result; + } + + /** + * Assemble a single Component. + */ + private async assembleComponent(data: EnrichedComponentData): Promise { + const { raw, envsData, depResolverData } = data; + + // Build ComponentFS from legacy files + const componentFS = ComponentFS.fromVinyls(raw.consumerComponent.files); + + // Build Config from extensions + const config = Config.fromExtensions(raw.extensions); + + // Upsert env and dependency resolver data into config + await this.upsertExtensionData(config, EnvsAspect.id, envsData); + await this.upsertExtensionData(config, DependencyResolverAspect.id, depResolverData); + + // Create aspect list with updated extensions + const aspectList = await this.createAspectList(config.extensions); + + // Build State + const state = new State(config, aspectList, componentFS, raw.consumerComponent.dependencies, raw.consumerComponent); + + // Create Component + const component = this.createComponent(raw.id, state); + + // Set tags if available + if (raw.consumerComponent.log) { + // TagMap would be built from log/versions + // For now, we'll use an empty TagMap as this requires more complex logic + } + + return component; + } + + /** + * Upsert extension data into config. + */ + private async upsertExtensionData(config: Config, extensionId: string, data: any): Promise { + if (!data) return; + + const existingExtension = config.extensions.findExtension(extensionId); + if (existingExtension) { + // Only merge top level of extension data + Object.assign(existingExtension.data, data); + return; + } + + // Add new extension + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { ExtensionDataEntry } = require('@teambit/legacy.extension-data'); + const entry = new ExtensionDataEntry(undefined, undefined, extensionId, undefined, data); + config.extensions.push(entry); + } +} + +/** + * Factory function for creating an AssemblyPhase + */ +export function createAssemblyPhase( + createComponent: (id: ComponentID, state: State) => Component, + createAspectList: (extensions: any) => Promise +): AssemblyPhase { + return new AssemblyPhase(createComponent, createAspectList); +} diff --git a/scopes/workspace/workspace/workspace-component/loader-v2/phases/discovery.phase.ts b/scopes/workspace/workspace/workspace-component/loader-v2/phases/discovery.phase.ts new file mode 100644 index 000000000000..f49c5dc4cb2e --- /dev/null +++ b/scopes/workspace/workspace/workspace-component/loader-v2/phases/discovery.phase.ts @@ -0,0 +1,103 @@ +import type { ComponentID } from '@teambit/component-id'; +import type { BitMap } from '@teambit/legacy.bit-map'; + +/** + * Result of the Discovery phase + */ +export interface DiscoveryResult { + /** All component IDs discovered */ + ids: ComponentID[]; + + /** IDs that exist in workspace (from .bitmap) */ + workspaceIds: ComponentID[]; + + /** IDs that need to be loaded from scope */ + scopeIds: ComponentID[]; + + /** IDs that were explicitly requested (subset of ids) */ + requestedIds: ComponentID[]; +} + +/** + * Discovery Phase + * + * Purpose: Find all ComponentIDs that need to be loaded. + * + * Input: Requested component IDs (or "all" to load entire workspace) + * Output: Categorized lists of IDs (workspace vs scope) + * + * This phase does NOT load any component data - it only identifies what needs to be loaded. + */ +export class DiscoveryPhase { + constructor( + private bitmap: BitMap, + private hasInScope: (id: ComponentID) => Promise + ) {} + + /** + * Discover which components need to be loaded and categorize them. + */ + async execute(requestedIds: ComponentID[]): Promise { + const workspaceIds: ComponentID[] = []; + const scopeIds: ComponentID[] = []; + + for (const id of requestedIds) { + const isInWorkspace = this.isInWorkspace(id); + if (isInWorkspace) { + workspaceIds.push(id); + } else { + // Check if it exists in scope + const isInScope = await this.hasInScope(id); + if (isInScope) { + scopeIds.push(id); + } + // If not in workspace or scope, it will be handled as an error later + } + } + + return { + ids: [...workspaceIds, ...scopeIds], + workspaceIds, + scopeIds, + requestedIds, + }; + } + + /** + * Discover all components in the workspace. + */ + async discoverAll(): Promise { + const allIds = this.bitmap.getAllBitIdsFromAllLanes(); + const componentIds = allIds.map((id) => id); + + return { + ids: componentIds, + workspaceIds: componentIds, + scopeIds: [], + requestedIds: componentIds, + }; + } + + /** + * Check if a component exists in the workspace (.bitmap) + */ + private isInWorkspace(id: ComponentID): boolean { + try { + // Try to get the component from bitmap + const idWithoutVersion = id.toStringWithoutVersion(); + return this.bitmap.hasId(idWithoutVersion); + } catch { + return false; + } + } +} + +/** + * Factory function for creating a DiscoveryPhase + */ +export function createDiscoveryPhase( + bitmap: BitMap, + hasInScope: (id: ComponentID) => Promise +): DiscoveryPhase { + return new DiscoveryPhase(bitmap, hasInScope); +} diff --git a/scopes/workspace/workspace/workspace-component/loader-v2/phases/enrichment.phase.ts b/scopes/workspace/workspace/workspace-component/loader-v2/phases/enrichment.phase.ts new file mode 100644 index 000000000000..964006d721cf --- /dev/null +++ b/scopes/workspace/workspace/workspace-component/loader-v2/phases/enrichment.phase.ts @@ -0,0 +1,156 @@ +import type { EnvsMain } from '@teambit/envs'; +import type { DependencyResolverMain } from '@teambit/dependency-resolver'; +import { Dependencies } from '@teambit/legacy.consumer-component'; +import type { ExtensionDataEntry } from '@teambit/legacy.extension-data'; +import type { RawComponentData } from '../component-source'; + +/** + * Enriched component data with env and dependency information + */ +export interface EnrichedComponentData { + raw: RawComponentData; + envsData: any; + depResolverData: any; +} + +/** + * Result of the Enrichment phase + */ +export interface EnrichmentResult { + /** Map of component ID string -> enriched data */ + enriched: Map; + + /** Components that failed enrichment */ + failed: Map; +} + +/** + * Enrichment Phase + * + * Purpose: Add aspects, extensions, and env descriptors to raw component data. + * + * Input: Map of RawComponentData + * Output: Map of EnrichedComponentData with env/deps information + * + * This phase: + * 1. Calculates environment descriptors + * 2. Merges variant policies + * 3. Extracts dependencies + * 4. Prepares data for aspect list creation + */ +export class EnrichmentPhase { + constructor( + private envs: EnvsMain, + private dependencyResolver: DependencyResolverMain, + private inInstallContext: boolean = false + ) {} + + /** + * Enrich all loaded components with env and dependency data. + */ + async execute(loaded: Map): Promise { + const result: EnrichmentResult = { + enriched: new Map(), + failed: new Map(), + }; + + // Process each component + for (const [idStr, rawData] of loaded) { + try { + const enriched = await this.enrichComponent(rawData); + result.enriched.set(idStr, enriched); + } catch (err: any) { + result.failed.set(idStr, err); + } + } + + return result; + } + + /** + * Enrich a single component. + */ + private async enrichComponent(raw: RawComponentData): Promise { + // For now, create a minimal Component-like object to pass to envs/deps resolver + // In a real implementation, this would need to be a proper Component instance + const componentLike: any = { + id: raw.id, + state: { + _consumer: raw.consumerComponent, + config: { + extensions: raw.extensions, + }, + }, + config: { + extensions: raw.extensions, + }, + }; + + // Calculate environment descriptor + const envsData = await this.envs.calcDescriptor(componentLike, { + skipWarnings: this.inInstallContext, + }); + + // Merge dependencies from workspace and model + const wsDeps = raw.consumerComponent.dependencies?.dependencies || []; + const modelDeps = raw.consumerComponent.componentFromModel?.dependencies?.dependencies || []; + const merged = Dependencies.merge([wsDeps, modelDeps]); + const envExtendsDeps = merged.get(); + + // Merge variant policies + const policy = await this.dependencyResolver.mergeVariantPolicies( + componentLike.config.extensions, + raw.id, + raw.consumerComponent.files, + envExtendsDeps + ); + + // Extract dependencies + const dependenciesList = await this.dependencyResolver.extractDepsFromLegacy(componentLike, policy); + + // Calculate env manifest + const resolvedEnvJsonc = await this.envs.calculateEnvManifest( + componentLike, + raw.consumerComponent.files, + envExtendsDeps + ); + if (resolvedEnvJsonc) { + // @ts-ignore + envsData.resolvedEnvJsonc = resolvedEnvJsonc; + } + + // Build dependency resolver data + const depResolverData = { + packageName: this.dependencyResolver.calcPackageName(componentLike), + dependencies: dependenciesList.serialize(), + policy: policy.serialize(), + componentRangePrefix: this.dependencyResolver.calcComponentRangePrefixByConsumerComponent(raw.consumerComponent), + }; + + return { + raw, + envsData, + depResolverData, + }; + } + + /** + * Create an extension data entry (helper for upsert) + */ + async createDataEntry(extension: string, data: { [key: string]: any }): Promise { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { ExtensionDataEntry } = require('@teambit/legacy.extension-data'); + return new ExtensionDataEntry(undefined, undefined, extension, undefined, data); + } +} + +/** + * Factory function for creating an EnrichmentPhase + */ +export function createEnrichmentPhase( + envs: EnvsMain, + dependencyResolver: DependencyResolverMain, + inInstallContext?: boolean +): EnrichmentPhase { + return new EnrichmentPhase(envs, dependencyResolver, inInstallContext); +} diff --git a/scopes/workspace/workspace/workspace-component/loader-v2/phases/execution.phase.ts b/scopes/workspace/workspace/workspace-component/loader-v2/phases/execution.phase.ts new file mode 100644 index 000000000000..ad3437d84b9c --- /dev/null +++ b/scopes/workspace/workspace/workspace-component/loader-v2/phases/execution.phase.ts @@ -0,0 +1,94 @@ +import type { Component } from '@teambit/component'; +import mapSeries from 'p-map-series'; +import type { ComponentLoadOptions } from '../../workspace-component-loader'; + +/** + * Callback type for onComponentLoad slot + */ +export type OnComponentLoadCallback = (component: Component, loadOpts?: ComponentLoadOptions) => Promise; + +/** + * Result of the Execution phase + */ +export interface ExecutionResult { + /** Components after execution (may have been modified by slots) */ + components: Map; + + /** Components that failed execution */ + failed: Map; +} + +/** + * Execution Phase + * + * Purpose: Run onComponentLoad slots to allow aspects to process components. + * + * Input: Map of Component objects + * Output: Same components after slot execution + * + * This phase: + * 1. Runs onComponentLoad slots for each component + * 2. Allows aspects to add/modify extension data + * 3. Updates aspect lists with new data + * 4. Returns fully initialized components + */ +export class ExecutionPhase { + constructor( + private onComponentLoadSlot: Array<[string, OnComponentLoadCallback]>, + private upsertExtensionData: (component: Component, extension: string, data: any) => Promise, + private resolveComponentId: (id: string) => Promise + ) {} + + /** + * Execute onComponentLoad slots for all components. + */ + async execute(components: Map, loadOpts?: ComponentLoadOptions): Promise { + const result: ExecutionResult = { + components: new Map(), + failed: new Map(), + }; + + for (const [idStr, component] of components) { + try { + // Skip if component was soft-removed + if (component.state._consumer.removed) { + result.components.set(idStr, component); + continue; + } + + await this.executeSlots(component, loadOpts); + result.components.set(idStr, component); + } catch (err: any) { + result.failed.set(idStr, err); + } + } + + return result; + } + + /** + * Execute slots for a single component. + */ + private async executeSlots(component: Component, loadOpts?: ComponentLoadOptions): Promise { + // Run each slot in series (order matters) + await mapSeries(this.onComponentLoadSlot, async ([extensionId, onLoad]) => { + const data = await onLoad(component, loadOpts); + await this.upsertExtensionData(component, extensionId, data); + + // Update the aspect list with new data + const resolvedId = await this.resolveComponentId(extensionId); + component.state.aspects.upsertEntry(resolvedId, data); + }); + } +} + +/** + * Factory function for creating an ExecutionPhase + */ +export function createExecutionPhase( + onComponentLoadSlot: Array<[string, OnComponentLoadCallback]>, + upsertExtensionData: (component: Component, extension: string, data: any) => Promise, + resolveComponentId: (id: string) => Promise +): ExecutionPhase { + return new ExecutionPhase(onComponentLoadSlot, upsertExtensionData, resolveComponentId); +} diff --git a/scopes/workspace/workspace/workspace-component/loader-v2/phases/hydration.phase.ts b/scopes/workspace/workspace/workspace-component/loader-v2/phases/hydration.phase.ts new file mode 100644 index 000000000000..e7fe2be5945d --- /dev/null +++ b/scopes/workspace/workspace/workspace-component/loader-v2/phases/hydration.phase.ts @@ -0,0 +1,117 @@ +import type { ComponentID } from '@teambit/component-id'; +import type { LoadPlan, LoadPhase } from '../load-plan'; +import type { ComponentSource, RawComponentData } from '../component-source'; + +/** + * Result of the Hydration phase + */ +export interface HydrationResult { + /** Successfully loaded raw component data */ + loaded: Map; + + /** Components that failed to load */ + failed: Map; + + /** Components that weren't found */ + notFound: ComponentID[]; +} + +/** + * Hydration Phase + * + * Purpose: Load raw component data from sources (workspace/scope). + * + * Input: LoadPlan with phases and IDs + * Output: Map of component ID -> RawComponentData + * + * This phase: + * 1. Iterates through phases in order + * 2. Loads components from appropriate sources + * 3. Does NOT enrich or transform data - just raw loading + */ +export class HydrationPhase { + constructor( + private workspaceSource: ComponentSource, + private scopeSource: ComponentSource + ) {} + + /** + * Execute hydration for all phases in the plan. + */ + async execute(plan: LoadPlan): Promise { + const result: HydrationResult = { + loaded: new Map(), + failed: new Map(), + notFound: [], + }; + + for (const phase of plan.phases) { + await this.hydratePhase(phase, result); + } + + return result; + } + + /** + * Hydrate a single phase. + */ + private async hydratePhase(phase: LoadPhase, result: HydrationResult): Promise { + // Load workspace components + if (phase.workspaceIds.length > 0) { + await this.loadFromSource(phase.workspaceIds, this.workspaceSource, result); + } + + // Load scope components + if (phase.scopeIds.length > 0) { + await this.loadFromSource(phase.scopeIds, this.scopeSource, result); + } + } + + /** + * Load components from a specific source. + */ + private async loadFromSource(ids: ComponentID[], source: ComponentSource, result: HydrationResult): Promise { + // Try batch loading first + try { + const loaded = await source.loadRawMany(ids); + for (const [idStr, data] of loaded) { + result.loaded.set(idStr, data); + } + + // Check for any IDs that weren't returned + const loadedIds = new Set(loaded.keys()); + for (const id of ids) { + const idStr = id.toString(); + if (!loadedIds.has(idStr) && !result.failed.has(idStr)) { + result.notFound.push(id); + } + } + } catch { + // If batch loading fails, try individually + for (const id of ids) { + const idStr = id.toString(); + try { + const data = await source.loadRaw(id); + result.loaded.set(idStr, data); + } catch (err: any) { + result.failed.set(idStr, err); + } + } + } + } + + /** + * Load a single component. + */ + async loadOne(id: ComponentID, source: 'workspace' | 'scope'): Promise { + const sourceToUse = source === 'workspace' ? this.workspaceSource : this.scopeSource; + return sourceToUse.loadRaw(id); + } +} + +/** + * Factory function for creating a HydrationPhase + */ +export function createHydrationPhase(workspaceSource: ComponentSource, scopeSource: ComponentSource): HydrationPhase { + return new HydrationPhase(workspaceSource, scopeSource); +} diff --git a/scopes/workspace/workspace/workspace-component/loader-v2/phases/index.ts b/scopes/workspace/workspace/workspace-component/loader-v2/phases/index.ts new file mode 100644 index 000000000000..421330f5c3a0 --- /dev/null +++ b/scopes/workspace/workspace/workspace-component/loader-v2/phases/index.ts @@ -0,0 +1,20 @@ +/** + * Pipeline Phases for Component Loader V2 + * + * Each phase has a clear input/output contract and can be tested independently. + * + * Pipeline flow: + * 1. Discovery - Find all ComponentIDs to load + * 2. Resolution - Build LoadPlan with dependency order + * 3. Hydration - Load raw data from sources + * 4. Enrichment - Add aspects, extensions, env descriptors + * 5. Assembly - Build Component objects + * 6. Execution - Run onComponentLoad slots + */ + +export { DiscoveryPhase, DiscoveryResult } from './discovery.phase'; +export { ResolutionPhase, ResolutionResult } from './resolution.phase'; +export { HydrationPhase, HydrationResult } from './hydration.phase'; +export { EnrichmentPhase, EnrichmentResult, EnrichedComponentData } from './enrichment.phase'; +export { AssemblyPhase, AssemblyResult } from './assembly.phase'; +export { ExecutionPhase, ExecutionResult, OnComponentLoadCallback } from './execution.phase'; diff --git a/scopes/workspace/workspace/workspace-component/loader-v2/phases/resolution.phase.ts b/scopes/workspace/workspace/workspace-component/loader-v2/phases/resolution.phase.ts new file mode 100644 index 000000000000..9c216ec7834b --- /dev/null +++ b/scopes/workspace/workspace/workspace-component/loader-v2/phases/resolution.phase.ts @@ -0,0 +1,215 @@ +import type { ComponentID } from '@teambit/component-id'; +import type { ExtensionDataList } from '@teambit/legacy.extension-data'; +import type { EnvsMain } from '@teambit/envs'; +import { LoadPlan, LoadPlanOptions, createEmptyPlan, createPhase } from '../load-plan'; +import type { DiscoveryResult } from './discovery.phase'; + +/** + * Result of the Resolution phase + */ +export interface ResolutionResult { + /** The complete load plan */ + plan: LoadPlan; + + /** Any warnings generated during resolution */ + warnings: string[]; +} + +/** + * Interface for getting extensions without fully loading a component + */ +export interface ExtensionResolver { + getExtensions(id: ComponentID): Promise; + getEnvId(id: ComponentID): Promise; +} + +/** + * Resolution Phase + * + * Purpose: Build a LoadPlan that determines the order components should be loaded. + * + * Input: DiscoveryResult (categorized component IDs) + * Output: LoadPlan with phases ordered by dependencies + * + * This phase: + * 1. Identifies which components are envs, extensions, or regular components + * 2. Discovers dependencies between components + * 3. Builds a topologically sorted load order + * 4. Groups components into phases for efficient loading + */ +export class ResolutionPhase { + constructor( + private envs: EnvsMain, + private extensionResolver: ExtensionResolver + ) {} + + /** + * Build a LoadPlan from discovered components. + */ + async execute(discovery: DiscoveryResult, options: LoadPlanOptions = {}): Promise { + const plan = createEmptyPlan(discovery.requestedIds); + const warnings: string[] = []; + + const { workspaceIds, scopeIds } = discovery; + const allIds = [...workspaceIds, ...scopeIds]; + + if (allIds.length === 0) { + return { plan, warnings }; + } + + // Step 1: Separate core envs from other components + const { coreEnvs, nonCoreEnvs } = this.separateCoreEnvs(allIds); + + // Step 2: If there are core envs, add them as first phase + if (coreEnvs.length > 0) { + const coreEnvPhase = createPhase('core-envs', 'core-envs', 'Core environments must load first', { + workspaceIds: coreEnvs.filter((id) => this.isInList(id, workspaceIds)), + scopeIds: coreEnvs.filter((id) => this.isInList(id, scopeIds)), + loadAsAspects: true, + }); + plan.phases.push(coreEnvPhase); + } + + // Step 3: For non-core components, discover their envs and extensions + const envIds = new Set(); + const extensionIds = new Set(); + + for (const id of nonCoreEnvs) { + try { + // Get env for this component + const envId = await this.extensionResolver.getEnvId(id); + if (envId && !this.envs.isCoreEnv(envId)) { + envIds.add(envId); + } + + // Get extensions for this component + if (options.loadExtensions) { + const extensions = await this.extensionResolver.getExtensions(id); + if (extensions) { + for (const ext of extensions) { + if (ext.stringId && ext.newExtensionId) { + extensionIds.add(ext.stringId); + } + } + } + } + } catch (err: any) { + warnings.push(`Failed to resolve dependencies for ${id.toString()}: ${err.message}`); + } + } + + // Step 4: Add envs phase (if any non-core envs) + const envIdsList = Array.from(envIds); + if (envIdsList.length > 0) { + const envsPhase = createPhase('envs', 'envs', 'Environments must load before their components', { + // For now, assume envs are in scope (simplified - real implementation would check) + scopeIds: [], // Will be populated by caller with actual ComponentID objects + loadAsAspects: true, + }); + plan.phases.push(envsPhase); + } + + // Step 5: Add extensions phase (if loading extensions) + const extensionIdsList = Array.from(extensionIds); + if (options.loadExtensions && extensionIdsList.length > 0) { + const extensionsPhase = createPhase( + 'extensions', + 'extensions', + 'Extensions must load before components that use them', + { + scopeIds: [], // Will be populated by caller with actual ComponentID objects + loadAsAspects: true, + } + ); + plan.phases.push(extensionsPhase); + } + + // Step 6: Add main components phase + const componentsPhase = createPhase('components', 'components', 'Main requested components', { + workspaceIds: nonCoreEnvs.filter((id) => this.isInList(id, workspaceIds)), + scopeIds: nonCoreEnvs.filter((id) => this.isInList(id, scopeIds)), + loadAsAspects: false, + }); + plan.phases.push(componentsPhase); + + // Update plan stats + this.updateStats(plan); + + return { plan, warnings }; + } + + /** + * Separate core envs from other components + */ + private separateCoreEnvs(ids: ComponentID[]): { coreEnvs: ComponentID[]; nonCoreEnvs: ComponentID[] } { + const coreEnvs: ComponentID[] = []; + const nonCoreEnvs: ComponentID[] = []; + + for (const id of ids) { + if (this.envs.isCoreEnv(id.toStringWithoutVersion())) { + coreEnvs.push(id); + } else { + nonCoreEnvs.push(id); + } + } + + return { coreEnvs, nonCoreEnvs }; + } + + /** + * Check if an ID is in a list (by string comparison) + */ + private isInList(id: ComponentID, list: ComponentID[]): boolean { + const idStr = id.toString(); + return list.some((listId) => listId.toString() === idStr); + } + + /** + * Update plan statistics + */ + private updateStats(plan: LoadPlan): void { + let totalComponents = 0; + let workspaceComponents = 0; + let scopeComponents = 0; + let envCount = 0; + let extensionCount = 0; + + for (const phase of plan.phases) { + const wsCount = phase.workspaceIds.length; + const scopeCount = phase.scopeIds.length; + + totalComponents += wsCount + scopeCount; + workspaceComponents += wsCount; + scopeComponents += scopeCount; + + if (phase.type === 'core-envs' || phase.type === 'envs' || phase.type === 'env-of-envs') { + envCount += wsCount + scopeCount; + } + if (phase.type === 'extensions') { + extensionCount += wsCount + scopeCount; + } + } + + plan.stats = { + totalComponents, + workspaceComponents, + scopeComponents, + phaseCount: plan.phases.length, + envCount, + extensionCount, + }; + + // Build load order (flattened phases) + plan.loadOrder = []; + for (const phase of plan.phases) { + plan.loadOrder.push(...phase.workspaceIds, ...phase.scopeIds); + } + } +} + +/** + * Factory function for creating a ResolutionPhase + */ +export function createResolutionPhase(envs: EnvsMain, extensionResolver: ExtensionResolver): ResolutionPhase { + return new ResolutionPhase(envs, extensionResolver); +}