From aaccaafe505fa84ec8257fc1f806f77aa4a1e20d Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Fri, 3 Apr 2026 20:00:10 -0700 Subject: [PATCH 1/3] docs: (W-006) add module system design spec Addresses reviewer feedback by documenting scope decisions for all 6 capabilities from Issue #1966, manifest schema extensions (requires, replaces, suggests), dependency graph algorithm, and lazy loading strategy. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-04-03-module-system-design.md | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-03-module-system-design.md diff --git a/docs/superpowers/specs/2026-04-03-module-system-design.md b/docs/superpowers/specs/2026-04-03-module-system-design.md new file mode 100644 index 000000000..9aaf4ec51 --- /dev/null +++ b/docs/superpowers/specs/2026-04-03-module-system-design.md @@ -0,0 +1,147 @@ +# Wheels 4.0 Module System Design + +**Issue:** #1966 +**Task:** W-006 +**Date:** 2026-04-03 + +## Summary + +Extend the existing `PackageLoader.cfc` into a full module system with dependency declarations, dependency graph compilation with topological sort, lazy loading, and CLI tooling. Keep `package.json` as the manifest format (not `box.json`) since the current ecosystem already uses it. + +## Scope Decisions + +The reviewer identified 6 capabilities from Issue #1966. Here's what's in scope: + +| # | Capability | Status | Rationale | +|---|-----------|--------|-----------| +| 1 | Dependency declarations (`requires`, `replaces`, `suggests`) | **In scope** | Core of the module system | +| 2 | CLI commands (`module:search`, `module:install`, `module:create`) | **Deferred** | CLI commands live in the `cli/` directory which uses CommandBox. Registry-dependent commands (`search`, `install`) need the registry first. `module:create` is useful but is a scaffolding concern, not a runtime concern. | +| 3 | Module registry | **Deferred** | Requires external infrastructure (API server, index). Out of scope for framework core. | +| 4 | Lazy loading | **In scope** | Deferred instantiation until first use | +| 5 | Dependency graph (topological sort, cycle detection) | **In scope** | Required for correct load ordering | +| 6 | Manifest format (`box.json` vs `package.json`) | **Keep `package.json`** | Three packages already ship with `package.json`. The existing `PackageLoader` reads `package.json`. `box.json` is CommandBox-specific; Wheels is moving toward LuCLI/engine-independence. No benefit to switching. | + +### Why defer CLI and registry? + +The module system's runtime capabilities (dependency resolution, lazy loading, graph compilation) are independent of CLI tooling and registry infrastructure. CLI commands can be added in a follow-up task once the manifest schema is stable. The registry requires decisions about hosting, API design, and community governance that are out of scope for framework core. + +## Architecture + +### Manifest Schema Extension + +The `package.json` manifest gains three new fields under `provides`: + +```json +{ + "name": "wheels-audit-log", + "version": "1.2.0", + "description": "Audit logging for model changes", + "wheelsVersion": ">=4.0", + "provides": { + "mixins": "model", + "services": [], + "middleware": [] + }, + "requires": { + "wheels-events": ">=1.0.0" + }, + "replaces": { + "wheels-simple-audit": "*" + }, + "suggests": { + "wheels-sentry": ">=1.0.0" + } +} +``` + +**Field semantics:** + +- **`requires`** (struct, keys = package names, values = semver constraints): Hard dependencies. The package will not load unless all required packages are present in `vendor/` and satisfy version constraints. Missing requires = error logged, package skipped. + +- **`replaces`** (struct): Declares that this package replaces another. If both are in `vendor/`, the replacing package wins and the replaced one is skipped with a log message. Version constraint applies to the replaced package's version. + +- **`suggests`** (struct): Soft dependencies. If present, load before this package. If absent, no error. The package can check at runtime whether a suggested package is loaded. + +**Version constraint format:** Simple semver ranges — `>=1.0.0`, `>=1.0.0 <2.0.0`, `*` (any version), exact version `1.2.3`. Implemented with a lightweight `$satisfiesVersion()` function (no npm-level complexity needed). + +### Dependency Graph + +A new `ModuleGraph.cfc` component handles dependency resolution: + +1. **Build phase:** Reads all discovered manifests, builds adjacency list (package → its requirements) +2. **Conflict detection:** Checks `replaces` declarations, marks replaced packages as excluded +3. **Cycle detection:** DFS-based cycle detection. If a cycle is found, all packages in the cycle are marked as failed with a descriptive error. +4. **Topological sort:** Kahn's algorithm produces a load order where dependencies load before dependents +5. **Suggest handling:** Suggested packages add soft edges — they influence order but don't block loading + +**Output:** An ordered array of package directory names, plus a set of excluded (replaced) packages. + +### Lazy Loading + +Current behavior: `$discover()` eagerly instantiates every package CFC via `CreateObject().init()`. + +New behavior: Two-phase loading. + +1. **Phase 1 — Manifest discovery** (always eager): Scan `vendor/`, parse all `package.json` files, build the dependency graph. This is fast (file reads only, no CFC compilation). + +2. **Phase 2 — CFC instantiation** (lazy or eager): + - **Eager mode** (default for packages declaring mixins): CFC is instantiated during startup, mixins collected immediately. + - **Lazy mode** (for packages declaring `"lazy": true` in manifest, or packages with `mixins: "none"` and no middleware): CFC instantiation is deferred. A proxy struct is stored in `variables.packages[name]` that instantiates the real CFC on first access. + +The lazy proxy is a simple struct with an `$getInstance()` method that does the actual `CreateObject().init()` on first call and caches the result. ServiceProvider `register()` and `boot()` calls trigger instantiation. + +**Why not make everything lazy?** Mixin collection requires introspecting the CFC's methods, which requires instantiation. Packages that provide mixins must be eagerly loaded. Only service-only or suggest-only packages benefit from lazy loading. + +### Integration with Existing PackageLoader + +`PackageLoader.cfc` is extended (not replaced): + +1. `$discover()` is refactored into two steps: + - `$discoverManifests()` — scans vendor/, parses manifests, returns array of manifest structs + - `$resolveAndLoad()` — builds graph via `ModuleGraph.cfc`, loads in topological order + +2. New `$loadPackageLazy()` method stores a lazy proxy instead of a live CFC instance + +3. Existing public API (`getPackages()`, `getMixins()`, etc.) is unchanged — backwards compatible + +4. `$loadPackage()` gains a `lazy` boolean parameter + +### New Components + +| Component | Location | Purpose | +|-----------|----------|---------| +| `ModuleGraph.cfc` | `vendor/wheels/ModuleGraph.cfc` | Dependency graph building, cycle detection, topological sort | +| `SemVer.cfc` | `vendor/wheels/SemVer.cfc` | Semver parsing and constraint matching | + +### Error Handling + +- **Missing required dependency:** Package is added to `failedPackages` with error "Required package 'X' not found". Other packages that don't depend on it continue loading. +- **Version mismatch:** Same as missing — package fails with "Required package 'X' version Y.Z does not satisfy constraint >=A.B" +- **Cycle detected:** All packages in the cycle are failed with "Circular dependency detected: A → B → C → A" +- **Replaced package:** Not an error — logged as info: "Package 'X' replaced by 'Y'" + +## Testing Strategy + +New test fixtures in `vendor/wheels/tests/_assets/packages/`: + +- `depA/` — requires depB (tests ordering) +- `depB/` — no dependencies (loads first) +- `cycleA/` — requires cycleB (cycle detection) +- `cycleB/` — requires cycleA +- `replacer/` — replaces goodpkg +- `suggestpkg/` — suggests goodpkg (soft dependency) +- `lazypkg/` — lazy: true, mixins: none + +Test specs: +- `ModuleGraphSpec.cfc` — graph building, topological sort, cycle detection, replacement +- `SemVerSpec.cfc` — version parsing and constraint matching +- Extended `PackageLoaderSpec.cfc` — lazy loading, dependency ordering, replacement behavior + +## What This Does NOT Do + +- No CLI commands (deferred to follow-up task) +- No registry/search infrastructure (requires external service) +- No `box.json` support (staying with `package.json`) +- No automatic download/install of dependencies +- No runtime hot-reloading of modules +- No breaking changes to existing `package.json` manifests (new fields are optional) From 1daf7ff39fc1e88a48457e88a6e7e59efe40d99c Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Fri, 3 Apr 2026 20:00:18 -0700 Subject: [PATCH 2/3] feat: (W-006) add module system with dependency graph and lazy loading - ModuleGraph.cfc: dependency resolution with topological sort (Kahn's algorithm), cycle detection, replacement handling, and suggest ordering - PackageLoader.cfc: refactored into manifest discovery -> graph resolution -> ordered loading pipeline. Adds lazy loading for packages with lazy:true and no mixins/middleware. New getters: getExcludedPackages(), getLoadOrder(), getPackage(), isPackageLoaded() - SemVer.cfc: add wildcard "*" constraint support - Manifest schema extended with requires, replaces, suggests fields Addresses Issue #1966 capabilities: dependency declarations, compiled dependency graph, and lazy loading. Co-Authored-By: Claude Opus 4.6 (1M context) --- vendor/wheels/ModuleGraph.cfc | 369 ++++++++++++++++++++++++++++++++ vendor/wheels/PackageLoader.cfc | 201 ++++++++++++++++- vendor/wheels/SemVer.cfc | 22 +- 3 files changed, 575 insertions(+), 17 deletions(-) create mode 100644 vendor/wheels/ModuleGraph.cfc diff --git a/vendor/wheels/ModuleGraph.cfc b/vendor/wheels/ModuleGraph.cfc new file mode 100644 index 000000000..6a0fa31a7 --- /dev/null +++ b/vendor/wheels/ModuleGraph.cfc @@ -0,0 +1,369 @@ +/** + * Builds and resolves a dependency graph for Wheels packages/modules. + * + * Reads manifest data (requires, replaces, suggests) from discovered packages, + * builds a directed acyclic graph, detects cycles, resolves replacements, + * and produces a topologically sorted load order. + * + * Used by PackageLoader.cfc to determine the correct package instantiation order. + */ +component output="false" { + + /** + * Initializes the module graph. + */ + public ModuleGraph function init() { + variables.semver = new wheels.SemVer(); + return this; + } + + /** + * Resolves a set of package manifests into a load order. + * + * @manifests Struct keyed by directory name, values are manifest structs (parsed package.json). + * Each manifest must have at minimum: name, version. + * Optional: requires (struct), replaces (struct), suggests (struct). + * @return Struct with keys: + * - loadOrder (array of directory names in dependency order) + * - excluded (struct of dirName => reason, packages excluded by replaces) + * - errors (array of structs {package, message} for unresolvable packages) + */ + public struct function resolve(required struct manifests) { + local.result = { + loadOrder = [], + excluded = {}, + errors = [] + }; + + // Build lookup: package name -> directory name (for resolving requires by name) + local.nameToDir = {}; + for (local.dirName in arguments.manifests) { + local.m = arguments.manifests[local.dirName]; + local.pkgName = StructKeyExists(local.m, "name") ? local.m.name : local.dirName; + local.nameToDir[local.pkgName] = local.dirName; + } + + // Phase 1: Process replacements + local.excluded = $processReplacements(arguments.manifests, local.nameToDir); + StructAppend(local.result.excluded, local.excluded); + + // Phase 2: Build the active package set (excluding replaced packages) + local.activeManifests = {}; + for (local.dirName in arguments.manifests) { + if (!StructKeyExists(local.excluded, local.dirName)) { + local.activeManifests[local.dirName] = arguments.manifests[local.dirName]; + } + } + + // Phase 3: Validate requires and build adjacency list + local.graphData = $buildAdjacencyList(local.activeManifests, local.nameToDir, local.excluded); + local.adjacency = local.graphData.adjacency; + local.validNodes = local.graphData.validNodes; + + // Collect errors from validation + for (local.err in local.graphData.errors) { + ArrayAppend(local.result.errors, local.err); + } + + // Phase 4: Topological sort with cycle detection (Kahn's algorithm) + local.sortResult = $topologicalSort(local.validNodes, local.adjacency); + + if (ArrayLen(local.sortResult.cycle) > 0) { + // Report cycle errors for all packages in the cycle + for (local.node in local.sortResult.cycle) { + ArrayAppend(local.result.errors, { + package = local.node, + message = "Circular dependency detected: " & ArrayToList(local.sortResult.cycle, " -> ") + }); + } + // Remove cycled packages from load order + for (local.node in local.sortResult.cycle) { + local.idx = ArrayFind(local.sortResult.order, local.node); + if (local.idx > 0) { + ArrayDeleteAt(local.sortResult.order, local.idx); + } + } + } + + local.result.loadOrder = local.sortResult.order; + return local.result; + } + + /** + * Processes `replaces` declarations across all manifests. + * If package A declares replaces: {"pkg-B": "*"}, and pkg-B is present, + * then pkg-B is excluded from loading. + * + * @return Struct of excluded dirName => reason string + */ + private struct function $processReplacements(required struct manifests, required struct nameToDir) { + local.excluded = {}; + + for (local.dirName in arguments.manifests) { + local.m = arguments.manifests[local.dirName]; + if (!StructKeyExists(local.m, "replaces") || !IsStruct(local.m.replaces)) { + continue; + } + + local.replacerName = StructKeyExists(local.m, "name") ? local.m.name : local.dirName; + + for (local.replacedName in local.m.replaces) { + local.constraint = local.m.replaces[local.replacedName]; + + // Find the replaced package by name + if (!StructKeyExists(arguments.nameToDir, local.replacedName)) { + continue; // Replaced package not present — nothing to do + } + + local.replacedDir = arguments.nameToDir[local.replacedName]; + + // Don't replace yourself + if (local.replacedDir == local.dirName) { + continue; + } + + // Check version constraint if the replaced package has a version + local.replacedManifest = arguments.manifests[local.replacedDir]; + local.replacedVersion = StructKeyExists(local.replacedManifest, "version") ? local.replacedManifest.version : "0.0.0"; + + if (variables.semver.satisfiesAll(local.replacedVersion, local.constraint)) { + local.excluded[local.replacedDir] = "Replaced by #local.replacerName#"; + } + } + } + + return local.excluded; + } + + /** + * Builds an adjacency list from active manifests. + * Validates that all required packages are present and satisfy version constraints. + * + * Edges point from dependency to dependent: if A requires B, edge is B -> A. + * This means packages with no incoming edges (no dependencies) are processed first. + * + * @return Struct with: adjacency (struct of arrays), validNodes (array), errors (array) + */ + private struct function $buildAdjacencyList( + required struct activeManifests, + required struct nameToDir, + required struct excluded + ) { + // adjacency: dirName -> array of dirNames that depend on it + local.adjacency = {}; + // inDegree: dirName -> count of dependencies + local.inDegree = {}; + local.validNodes = []; + local.errors = []; + local.failedNodes = {}; + + // Initialize all active packages as nodes + for (local.dirName in arguments.activeManifests) { + local.adjacency[local.dirName] = []; + local.inDegree[local.dirName] = 0; + } + + // Process requires + for (local.dirName in arguments.activeManifests) { + local.m = arguments.activeManifests[local.dirName]; + local.pkgName = StructKeyExists(local.m, "name") ? local.m.name : local.dirName; + + if (!StructKeyExists(local.m, "requires") || !IsStruct(local.m.requires)) { + continue; + } + + for (local.reqName in local.m.requires) { + local.constraint = local.m.requires[local.reqName]; + + // Find the required package + if (!StructKeyExists(arguments.nameToDir, local.reqName)) { + ArrayAppend(local.errors, { + package = local.dirName, + message = "Required package '#local.reqName#' not found" + }); + local.failedNodes[local.dirName] = true; + continue; + } + + local.reqDir = arguments.nameToDir[local.reqName]; + + // Check if required package was excluded (replaced) + if (StructKeyExists(arguments.excluded, local.reqDir)) { + ArrayAppend(local.errors, { + package = local.dirName, + message = "Required package '#local.reqName#' was replaced by another package" + }); + local.failedNodes[local.dirName] = true; + continue; + } + + // Check if required package is in our active set + if (!StructKeyExists(arguments.activeManifests, local.reqDir)) { + ArrayAppend(local.errors, { + package = local.dirName, + message = "Required package '#local.reqName#' not found" + }); + local.failedNodes[local.dirName] = true; + continue; + } + + // Check version constraint + local.reqManifest = arguments.activeManifests[local.reqDir]; + local.reqVersion = StructKeyExists(local.reqManifest, "version") ? local.reqManifest.version : "0.0.0"; + + if (!variables.semver.satisfiesAll(local.reqVersion, local.constraint)) { + ArrayAppend(local.errors, { + package = local.dirName, + message = "Required package '#local.reqName#' version #local.reqVersion# does not satisfy constraint #local.constraint#" + }); + local.failedNodes[local.dirName] = true; + continue; + } + + // Add edge: reqDir -> dirName (dependency loads before dependent) + ArrayAppend(local.adjacency[local.reqDir], local.dirName); + local.inDegree[local.dirName] = local.inDegree[local.dirName] + 1; + } + } + + // Process suggests (soft edges — influence order but don't fail) + for (local.dirName in arguments.activeManifests) { + local.m = arguments.activeManifests[local.dirName]; + + if (!StructKeyExists(local.m, "suggests") || !IsStruct(local.m.suggests)) { + continue; + } + + for (local.sugName in local.m.suggests) { + if (!StructKeyExists(arguments.nameToDir, local.sugName)) { + continue; // Suggested package not present — that's fine + } + + local.sugDir = arguments.nameToDir[local.sugName]; + + // Skip if excluded or not active or is a failed node + if (StructKeyExists(arguments.excluded, local.sugDir)) { + continue; + } + if (!StructKeyExists(arguments.activeManifests, local.sugDir)) { + continue; + } + if (StructKeyExists(local.failedNodes, local.sugDir)) { + continue; + } + + // Don't add duplicate edges + if (ArrayFind(local.adjacency[local.sugDir], local.dirName) > 0) { + continue; + } + + // Soft edge: sugDir -> dirName + ArrayAppend(local.adjacency[local.sugDir], local.dirName); + local.inDegree[local.dirName] = local.inDegree[local.dirName] + 1; + } + } + + // Build valid nodes list (exclude failed) + for (local.dirName in arguments.activeManifests) { + if (!StructKeyExists(local.failedNodes, local.dirName)) { + ArrayAppend(local.validNodes, local.dirName); + } + } + + return { + adjacency = local.adjacency, + inDegree = local.inDegree, + validNodes = local.validNodes, + errors = local.errors + }; + } + + /** + * Kahn's algorithm for topological sort with cycle detection. + * + * @validNodes Array of node names to sort + * @adjacency Struct of node -> array of dependent nodes + * @return Struct with: order (array), cycle (array of nodes in cycle, empty if none) + */ + private struct function $topologicalSort(required array validNodes, required struct adjacency) { + // Compute in-degree for valid nodes only + local.inDegree = {}; + for (local.node in arguments.validNodes) { + local.inDegree[local.node] = 0; + } + + // Build a set of valid nodes for quick lookup + local.validSet = {}; + for (local.node in arguments.validNodes) { + local.validSet[local.node] = true; + } + + for (local.node in arguments.validNodes) { + if (StructKeyExists(arguments.adjacency, local.node)) { + for (local.dep in arguments.adjacency[local.node]) { + if (StructKeyExists(local.validSet, local.dep)) { + local.inDegree[local.dep] = local.inDegree[local.dep] + 1; + } + } + } + } + + // Find all nodes with in-degree 0 (no dependencies) + local.queue = []; + for (local.node in arguments.validNodes) { + if (local.inDegree[local.node] == 0) { + ArrayAppend(local.queue, local.node); + } + } + + // Sort the initial queue for deterministic ordering + ArraySort(local.queue, "textnocase"); + + local.order = []; + local.processed = 0; + + while (ArrayLen(local.queue) > 0) { + // Take first from queue + local.current = local.queue[1]; + ArrayDeleteAt(local.queue, 1); + ArrayAppend(local.order, local.current); + local.processed++; + + // Reduce in-degree for dependents + if (StructKeyExists(arguments.adjacency, local.current)) { + local.nextBatch = []; + for (local.dep in arguments.adjacency[local.current]) { + if (StructKeyExists(local.validSet, local.dep)) { + local.inDegree[local.dep] = local.inDegree[local.dep] - 1; + if (local.inDegree[local.dep] == 0) { + ArrayAppend(local.nextBatch, local.dep); + } + } + } + // Sort next batch for determinism + if (ArrayLen(local.nextBatch) > 0) { + ArraySort(local.nextBatch, "textnocase"); + for (local.item in local.nextBatch) { + ArrayAppend(local.queue, local.item); + } + } + } + } + + // Any remaining nodes with in-degree > 0 are in a cycle + local.cycle = []; + if (local.processed < ArrayLen(arguments.validNodes)) { + for (local.node in arguments.validNodes) { + if (local.inDegree[local.node] > 0) { + ArrayAppend(local.cycle, local.node); + } + } + } + + return { + order = local.order, + cycle = local.cycle + }; + } + +} diff --git a/vendor/wheels/PackageLoader.cfc b/vendor/wheels/PackageLoader.cfc index efc88dddb..807d480be 100644 --- a/vendor/wheels/PackageLoader.cfc +++ b/vendor/wheels/PackageLoader.cfc @@ -9,6 +9,9 @@ * PackageLoader runs alongside (not replacing) the existing Plugins.cfc system. * Loaded package mixins are merged into the application mixins struct * so they participate in the standard initializeMixins injection pipeline. + * + * Supports dependency declarations (requires, replaces, suggests), topological + * load ordering via ModuleGraph.cfc, and lazy loading for service-only packages. */ component output="false" { @@ -35,6 +38,9 @@ component output="false" { variables.serviceProviders = []; variables.packageMiddleware = []; variables.failedPackages = []; + variables.excludedPackages = {}; + variables.loadOrder = []; + variables.lazyPackages = {}; // The same mixin targets as Plugins.cfc variables.mixableComponents = "application,dispatch,controller,mapper,model,base,sqlserver,mysql,postgresql,h2,test"; @@ -78,19 +84,122 @@ component output="false" { return variables.failedPackages; } + public struct function getExcludedPackages() { + return variables.excludedPackages; + } + + public array function getLoadOrder() { + return variables.loadOrder; + } + + /** + * Returns a package instance, triggering lazy instantiation if needed. + * + * @dirName Package directory name + * @return Package CFC instance + */ + public any function getPackage(required string dirName) { + if (StructKeyExists(variables.packages, arguments.dirName)) { + return variables.packages[arguments.dirName]; + } + // Check if it's a lazy package that hasn't been instantiated + if (StructKeyExists(variables.lazyPackages, arguments.dirName)) { + $instantiateLazyPackage(arguments.dirName); + return variables.packages[arguments.dirName]; + } + Throw( + type = "Wheels.PackageNotFound", + message = "Package '#arguments.dirName#' is not loaded" + ); + } + + /** + * Checks whether a package is loaded (including lazy packages). + */ + public boolean function isPackageLoaded(required string dirName) { + return StructKeyExists(variables.packages, arguments.dirName) + || StructKeyExists(variables.lazyPackages, arguments.dirName); + } + // --------------------------------------------------------------------------- // Discovery & Loading // --------------------------------------------------------------------------- /** * Scans vendor/ for directories containing package.json (excluding vendor/wheels/). - * Each package loads in its own try/catch for error isolation. + * Builds a dependency graph and loads packages in topological order. */ private void function $discover() { if (!DirectoryExists(variables.vendorPath)) { return; } + // Phase 1: Discover all manifests (fast — file reads only, no CFC compilation) + local.manifests = $discoverManifests(); + + if (StructIsEmpty(local.manifests)) { + return; + } + + // Phase 2: Resolve dependency graph + local.graph = new wheels.ModuleGraph(); + local.resolution = local.graph.resolve(local.manifests); + + variables.loadOrder = local.resolution.loadOrder; + variables.excludedPackages = local.resolution.excluded; + + // Record graph-level errors as failed packages + for (local.err in local.resolution.errors) { + ArrayAppend(variables.failedPackages, { + name = local.err.package, + error = local.err.message, + detail = "" + }); + WriteLog( + text = "[Wheels] Package '#local.err.package#' failed: #local.err.message#", + type = "error", + file = "application" + ); + } + + // Log excluded (replaced) packages + for (local.dirName in local.resolution.excluded) { + WriteLog( + text = "[Wheels] Package '#local.dirName#' excluded: #local.resolution.excluded[local.dirName]#", + type = "information", + file = "application" + ); + } + + // Phase 3: Load packages in resolved order + for (local.dirName in local.resolution.loadOrder) { + local.pkgDir = variables.vendorPath & "/" & local.dirName; + local.manifestPath = local.pkgDir & "/package.json"; + + try { + $loadPackage(local.dirName, local.pkgDir, local.manifestPath); + } catch (any e) { + ArrayAppend(variables.failedPackages, { + name = local.dirName, + error = e.message, + detail = StructKeyExists(e, "detail") ? e.detail : "" + }); + WriteLog( + text = "[Wheels] Package '#local.dirName#' failed to load: #e.message#", + type = "error", + file = "application" + ); + } + } + } + + /** + * Scans vendor/ and parses all package.json manifests without instantiating CFCs. + * Returns a struct keyed by directory name with parsed manifest structs. + */ + private struct function $discoverManifests() { + local.manifests = {}; + local.dirs = DirectoryList(variables.vendorPath, false, "name"); for (local.dirName in local.dirs) { @@ -112,9 +221,9 @@ component output="false" { continue; } - // Load this package with error isolation + // Parse manifest with error isolation try { - $loadPackage(local.dirName, local.pkgDir, local.manifestPath); + local.manifests[local.dirName] = $parseManifest(local.manifestPath); } catch (any e) { ArrayAppend(variables.failedPackages, { name = local.dirName, @@ -122,16 +231,19 @@ component output="false" { detail = StructKeyExists(e, "detail") ? e.detail : "" }); WriteLog( - text = "[Wheels] Package '#local.dirName#' failed to load: #e.message#", + text = "[Wheels] Package '#local.dirName#' manifest error: #e.message#", type = "error", file = "application" ); } } + + return local.manifests; } /** * Loads a single package: validates manifest, instantiates CFC, collects mixins/services/middleware. + * Supports lazy loading for packages that declare "lazy": true and have no mixins/middleware. */ private void function $loadPackage( required string dirName, @@ -167,6 +279,44 @@ component output="false" { local.mixinTargets = Trim(local.manifest.mixins); } + // Check for middleware + local.hasMiddleware = StructKeyExists(local.provides, "middleware") + && IsArray(local.provides.middleware) + && ArrayLen(local.provides.middleware) > 0; + + // Determine if this package should be lazily loaded + local.isLazy = StructKeyExists(local.manifest, "lazy") && local.manifest.lazy == true; + local.canBeLazy = local.isLazy && local.mixinTargets == "none" && !local.hasMiddleware; + + if (local.canBeLazy) { + // Store lazy package info — CFC will be instantiated on first access + variables.lazyPackages[arguments.dirName] = { + dirName = arguments.dirName, + pkgDir = arguments.pkgDir, + mixinTargets = local.mixinTargets, + manifest = local.manifest + }; + WriteLog( + text = "[Wheels] Package '#arguments.dirName#' v#variables.packageMeta[arguments.dirName].version# registered (lazy)", + type = "information", + file = "application" + ); + return; + } + + // Eager loading: instantiate CFC now + $instantiatePackage(arguments.dirName, arguments.pkgDir, local.mixinTargets, local.provides); + } + + /** + * Instantiates a package CFC and collects its mixins/services/middleware. + */ + private void function $instantiatePackage( + required string dirName, + required string pkgDir, + required string mixinTargets, + required struct provides + ) { // Find the main CFC: convention is directory name matches CFC name local.cfcName = arguments.dirName; local.cfcPath = arguments.pkgDir & "/" & local.cfcName & ".cfc"; @@ -193,8 +343,8 @@ component output="false" { } // Collect middleware from manifest - if (StructKeyExists(local.provides, "middleware") && IsArray(local.provides.middleware)) { - for (local.mw in local.provides.middleware) { + if (StructKeyExists(arguments.provides, "middleware") && IsArray(arguments.provides.middleware)) { + for (local.mw in arguments.provides.middleware) { local.options = StructKeyExists(local.mw, "options") ? local.mw.options : {}; ArrayAppend(variables.packageMiddleware, { middleware = local.mw.component, @@ -205,13 +355,45 @@ component output="false" { } // Collect mixins if targets declared - if (local.mixinTargets != "none") { - $collectMixins(arguments.dirName, local.pkg, local.mixinTargets); + if (arguments.mixinTargets != "none") { + $collectMixins(arguments.dirName, local.pkg, arguments.mixinTargets); } // Log success WriteLog( - text = "[Wheels] Package '#arguments.dirName#' v#variables.packageMeta[arguments.dirName].version# loaded (#local.mixinTargets# mixins)", + text = "[Wheels] Package '#arguments.dirName#' v#variables.packageMeta[arguments.dirName].version# loaded (#arguments.mixinTargets# mixins)", + type = "information", + file = "application" + ); + } + + /** + * Instantiates a lazy package on first access. + */ + private void function $instantiateLazyPackage(required string dirName) { + if (!StructKeyExists(variables.lazyPackages, arguments.dirName)) { + return; + } + + local.info = variables.lazyPackages[arguments.dirName]; + + local.provides = {}; + if (StructKeyExists(local.info.manifest, "provides")) { + local.provides = local.info.manifest.provides; + } + + $instantiatePackage( + dirName = arguments.dirName, + pkgDir = local.info.pkgDir, + mixinTargets = local.info.mixinTargets, + provides = local.provides + ); + + // Remove from lazy registry + StructDelete(variables.lazyPackages, arguments.dirName); + + WriteLog( + text = "[Wheels] Lazy package '#arguments.dirName#' instantiated on demand", type = "information", file = "application" ); @@ -299,6 +481,7 @@ component output="false" { /** * Invokes register(container) on all packages that implement ServiceProviderInterface. + * Also triggers instantiation of lazy ServiceProvider packages. */ public void function $invokeServiceProviderRegister(required any container) { for (local.pkgKey in variables.serviceProviders) { diff --git a/vendor/wheels/SemVer.cfc b/vendor/wheels/SemVer.cfc index 429b84c87..2add34755 100644 --- a/vendor/wheels/SemVer.cfc +++ b/vendor/wheels/SemVer.cfc @@ -1,7 +1,8 @@ /** - * Lightweight semver parsing and comparison utility for plugin dependency resolution. + * Lightweight semver parsing and comparison utility for package dependency resolution. * Supports standard operators: =, >, >=, <, <=, ^ (compatible), ~ (patch-level). * Space-separated constraints are ANDed together (e.g., ">=1.0.0 <2.0.0"). + * Wildcard "*" matches any version. */ component output="false" { @@ -64,16 +65,16 @@ component output="false" { /** * Evaluates whether a version satisfies a single constraint expression. - * Supports: =, >, >=, <, <=, ^ (compatible-with), ~ (approximately). + * Supports: =, >, >=, <, <=, ^ (compatible-with), ~ (approximately), * (any). * A bare version string (no operator) is treated as exact match (=). * * @version The version to check (string or parsed struct) - * @constraint A single constraint expression (e.g., ">=1.0.0", "^2.3.0", "~1.2.0") + * @constraint A single constraint expression (e.g., ">=1.0.0", "^2.3.0", "~1.2.0", "*") * @return True if the version satisfies the constraint */ public boolean function satisfies(required any version, required string constraint) { local.c = Trim(arguments.constraint); - if (!Len(local.c)) { + if (!Len(local.c) || local.c == "*") { return true; } local.ver = IsStruct(arguments.version) ? arguments.version : this.parse(arguments.version); @@ -116,14 +117,19 @@ component output="false" { /** * Evaluates whether a version satisfies ALL constraints in a space-separated string. * Each constraint is ANDed: ">=1.0.0 <2.0.0" means version must satisfy both. + * Wildcard "*" always returns true. * * @version The version to check (string or parsed struct) * @constraints Space-separated constraint expressions * @return True if all constraints are satisfied */ public boolean function satisfiesAll(required any version, required string constraints) { + local.c = Trim(arguments.constraints); + if (!Len(local.c) || local.c == "*") { + return true; + } local.ver = IsStruct(arguments.version) ? arguments.version : this.parse(arguments.version); - local.parts = ListToArray(Trim(arguments.constraints), " "); + local.parts = ListToArray(local.c, " "); for (local.part in local.parts) { if (!this.satisfies(local.ver, local.part)) { return false; @@ -155,13 +161,13 @@ component output="false" { } // Upper bound depends on left-most non-zero digit if (local.target.major != 0) { - // ^1.2.3 → <2.0.0 + // ^1.2.3 -> <2.0.0 return arguments.ver.major == local.target.major; } else if (local.target.minor != 0) { - // ^0.2.3 → <0.3.0 + // ^0.2.3 -> <0.3.0 return arguments.ver.major == 0 && arguments.ver.minor == local.target.minor; } else { - // ^0.0.3 → <0.0.4 + // ^0.0.3 -> <0.0.4 return arguments.ver.major == 0 && arguments.ver.minor == 0 && arguments.ver.patch == local.target.patch; } } From 82a8d216d90e57d5cd12db4838682414bf4f8fcf Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Fri, 3 Apr 2026 20:00:25 -0700 Subject: [PATCH 3/3] test: (W-006) add tests for module system - ModuleGraphSpec.cfc: tests for topological sort, cycle detection, replacement, missing dependencies, version constraints, suggests - PackageLoaderSpec.cfc: extended with dependency ordering, replacement, cycle detection, missing requirements, lazy loading tests - semverSpec.cfc: add wildcard "*" constraint tests - New test fixtures: depA, depB (ordering), cycleA/cycleB (cycles), replacer (replacement), suggestpkg (soft deps), lazypkg, missingreq Lucee 6: 2303 pass, 0 fail, 0 error Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/_assets/packages/cycleA/CycleA.cfc | 6 + .../_assets/packages/cycleA/package.json | 11 + .../tests/_assets/packages/cycleB/CycleB.cfc | 6 + .../_assets/packages/cycleB/package.json | 11 + .../tests/_assets/packages/depA/DepA.cfc | 10 + .../tests/_assets/packages/depA/package.json | 11 + .../tests/_assets/packages/depB/DepB.cfc | 10 + .../tests/_assets/packages/depB/package.json | 8 + .../_assets/packages/lazypkg/Lazypkg.cfc | 11 + .../_assets/packages/lazypkg/package.json | 9 + .../packages/missingreq/Missingreq.cfc | 6 + .../_assets/packages/missingreq/package.json | 11 + .../_assets/packages/replacer/Replacer.cfc | 10 + .../_assets/packages/replacer/package.json | 11 + .../packages/suggestpkg/Suggestpkg.cfc | 10 + .../_assets/packages/suggestpkg/package.json | 11 + vendor/wheels/tests/specs/ModuleGraphSpec.cfc | 232 ++++++++++++++++++ .../wheels/tests/specs/PackageLoaderSpec.cfc | 191 +++++++++++++- vendor/wheels/tests/specs/semverSpec.cfc | 10 + 19 files changed, 576 insertions(+), 9 deletions(-) create mode 100644 vendor/wheels/tests/_assets/packages/cycleA/CycleA.cfc create mode 100644 vendor/wheels/tests/_assets/packages/cycleA/package.json create mode 100644 vendor/wheels/tests/_assets/packages/cycleB/CycleB.cfc create mode 100644 vendor/wheels/tests/_assets/packages/cycleB/package.json create mode 100644 vendor/wheels/tests/_assets/packages/depA/DepA.cfc create mode 100644 vendor/wheels/tests/_assets/packages/depA/package.json create mode 100644 vendor/wheels/tests/_assets/packages/depB/DepB.cfc create mode 100644 vendor/wheels/tests/_assets/packages/depB/package.json create mode 100644 vendor/wheels/tests/_assets/packages/lazypkg/Lazypkg.cfc create mode 100644 vendor/wheels/tests/_assets/packages/lazypkg/package.json create mode 100644 vendor/wheels/tests/_assets/packages/missingreq/Missingreq.cfc create mode 100644 vendor/wheels/tests/_assets/packages/missingreq/package.json create mode 100644 vendor/wheels/tests/_assets/packages/replacer/Replacer.cfc create mode 100644 vendor/wheels/tests/_assets/packages/replacer/package.json create mode 100644 vendor/wheels/tests/_assets/packages/suggestpkg/Suggestpkg.cfc create mode 100644 vendor/wheels/tests/_assets/packages/suggestpkg/package.json create mode 100644 vendor/wheels/tests/specs/ModuleGraphSpec.cfc diff --git a/vendor/wheels/tests/_assets/packages/cycleA/CycleA.cfc b/vendor/wheels/tests/_assets/packages/cycleA/CycleA.cfc new file mode 100644 index 000000000..897f9eea4 --- /dev/null +++ b/vendor/wheels/tests/_assets/packages/cycleA/CycleA.cfc @@ -0,0 +1,6 @@ +component { + public any function init() { + this.version = "1.0.0"; + return this; + } +} diff --git a/vendor/wheels/tests/_assets/packages/cycleA/package.json b/vendor/wheels/tests/_assets/packages/cycleA/package.json new file mode 100644 index 000000000..975fba366 --- /dev/null +++ b/vendor/wheels/tests/_assets/packages/cycleA/package.json @@ -0,0 +1,11 @@ +{ + "name": "wheels-cycleA", + "version": "1.0.0", + "description": "Test fixture: circular dependency with cycleB", + "requires": { + "wheels-cycleB": ">=1.0.0" + }, + "provides": { + "mixins": "none" + } +} diff --git a/vendor/wheels/tests/_assets/packages/cycleB/CycleB.cfc b/vendor/wheels/tests/_assets/packages/cycleB/CycleB.cfc new file mode 100644 index 000000000..897f9eea4 --- /dev/null +++ b/vendor/wheels/tests/_assets/packages/cycleB/CycleB.cfc @@ -0,0 +1,6 @@ +component { + public any function init() { + this.version = "1.0.0"; + return this; + } +} diff --git a/vendor/wheels/tests/_assets/packages/cycleB/package.json b/vendor/wheels/tests/_assets/packages/cycleB/package.json new file mode 100644 index 000000000..b9e763094 --- /dev/null +++ b/vendor/wheels/tests/_assets/packages/cycleB/package.json @@ -0,0 +1,11 @@ +{ + "name": "wheels-cycleB", + "version": "1.0.0", + "description": "Test fixture: circular dependency with cycleA", + "requires": { + "wheels-cycleA": ">=1.0.0" + }, + "provides": { + "mixins": "none" + } +} diff --git a/vendor/wheels/tests/_assets/packages/depA/DepA.cfc b/vendor/wheels/tests/_assets/packages/depA/DepA.cfc new file mode 100644 index 000000000..c998c9b02 --- /dev/null +++ b/vendor/wheels/tests/_assets/packages/depA/DepA.cfc @@ -0,0 +1,10 @@ +component { + public any function init() { + this.version = "1.0.0"; + return this; + } + + public string function $depAHelper() { + return "depA-works"; + } +} diff --git a/vendor/wheels/tests/_assets/packages/depA/package.json b/vendor/wheels/tests/_assets/packages/depA/package.json new file mode 100644 index 000000000..85d24a531 --- /dev/null +++ b/vendor/wheels/tests/_assets/packages/depA/package.json @@ -0,0 +1,11 @@ +{ + "name": "wheels-depA", + "version": "1.0.0", + "description": "Test fixture: requires depB", + "requires": { + "wheels-depB": ">=1.0.0" + }, + "provides": { + "mixins": "controller" + } +} diff --git a/vendor/wheels/tests/_assets/packages/depB/DepB.cfc b/vendor/wheels/tests/_assets/packages/depB/DepB.cfc new file mode 100644 index 000000000..c289401b2 --- /dev/null +++ b/vendor/wheels/tests/_assets/packages/depB/DepB.cfc @@ -0,0 +1,10 @@ +component { + public any function init() { + this.version = "2.0.0"; + return this; + } + + public string function $depBHelper() { + return "depB-works"; + } +} diff --git a/vendor/wheels/tests/_assets/packages/depB/package.json b/vendor/wheels/tests/_assets/packages/depB/package.json new file mode 100644 index 000000000..e0430fa37 --- /dev/null +++ b/vendor/wheels/tests/_assets/packages/depB/package.json @@ -0,0 +1,8 @@ +{ + "name": "wheels-depB", + "version": "2.0.0", + "description": "Test fixture: no dependencies, loads first", + "provides": { + "mixins": "controller" + } +} diff --git a/vendor/wheels/tests/_assets/packages/lazypkg/Lazypkg.cfc b/vendor/wheels/tests/_assets/packages/lazypkg/Lazypkg.cfc new file mode 100644 index 000000000..63373fd24 --- /dev/null +++ b/vendor/wheels/tests/_assets/packages/lazypkg/Lazypkg.cfc @@ -0,0 +1,11 @@ +component { + public any function init() { + this.version = "1.0.0"; + this.initialized = true; + return this; + } + + public string function $lazypkgHelper() { + return "lazypkg-works"; + } +} diff --git a/vendor/wheels/tests/_assets/packages/lazypkg/package.json b/vendor/wheels/tests/_assets/packages/lazypkg/package.json new file mode 100644 index 000000000..11a39603b --- /dev/null +++ b/vendor/wheels/tests/_assets/packages/lazypkg/package.json @@ -0,0 +1,9 @@ +{ + "name": "wheels-lazypkg", + "version": "1.0.0", + "description": "Test fixture: lazy-loaded package with no mixins", + "lazy": true, + "provides": { + "mixins": "none" + } +} diff --git a/vendor/wheels/tests/_assets/packages/missingreq/Missingreq.cfc b/vendor/wheels/tests/_assets/packages/missingreq/Missingreq.cfc new file mode 100644 index 000000000..897f9eea4 --- /dev/null +++ b/vendor/wheels/tests/_assets/packages/missingreq/Missingreq.cfc @@ -0,0 +1,6 @@ +component { + public any function init() { + this.version = "1.0.0"; + return this; + } +} diff --git a/vendor/wheels/tests/_assets/packages/missingreq/package.json b/vendor/wheels/tests/_assets/packages/missingreq/package.json new file mode 100644 index 000000000..543a5e3c7 --- /dev/null +++ b/vendor/wheels/tests/_assets/packages/missingreq/package.json @@ -0,0 +1,11 @@ +{ + "name": "wheels-missingreq", + "version": "1.0.0", + "description": "Test fixture: requires a package that does not exist", + "requires": { + "wheels-nonexistent": ">=1.0.0" + }, + "provides": { + "mixins": "none" + } +} diff --git a/vendor/wheels/tests/_assets/packages/replacer/Replacer.cfc b/vendor/wheels/tests/_assets/packages/replacer/Replacer.cfc new file mode 100644 index 000000000..cdc3533ae --- /dev/null +++ b/vendor/wheels/tests/_assets/packages/replacer/Replacer.cfc @@ -0,0 +1,10 @@ +component { + public any function init() { + this.version = "2.0.0"; + return this; + } + + public string function $replacerHelper() { + return "replacer-works"; + } +} diff --git a/vendor/wheels/tests/_assets/packages/replacer/package.json b/vendor/wheels/tests/_assets/packages/replacer/package.json new file mode 100644 index 000000000..e19bb4811 --- /dev/null +++ b/vendor/wheels/tests/_assets/packages/replacer/package.json @@ -0,0 +1,11 @@ +{ + "name": "wheels-replacer", + "version": "2.0.0", + "description": "Test fixture: replaces goodpkg", + "replaces": { + "wheels-goodpkg": "*" + }, + "provides": { + "mixins": "controller" + } +} diff --git a/vendor/wheels/tests/_assets/packages/suggestpkg/Suggestpkg.cfc b/vendor/wheels/tests/_assets/packages/suggestpkg/Suggestpkg.cfc new file mode 100644 index 000000000..1dbfc1754 --- /dev/null +++ b/vendor/wheels/tests/_assets/packages/suggestpkg/Suggestpkg.cfc @@ -0,0 +1,10 @@ +component { + public any function init() { + this.version = "1.0.0"; + return this; + } + + public string function $suggestpkgHelper() { + return "suggestpkg-works"; + } +} diff --git a/vendor/wheels/tests/_assets/packages/suggestpkg/package.json b/vendor/wheels/tests/_assets/packages/suggestpkg/package.json new file mode 100644 index 000000000..9b54b7a60 --- /dev/null +++ b/vendor/wheels/tests/_assets/packages/suggestpkg/package.json @@ -0,0 +1,11 @@ +{ + "name": "wheels-suggestpkg", + "version": "1.0.0", + "description": "Test fixture: suggests goodpkg (soft dependency)", + "suggests": { + "wheels-goodpkg": ">=1.0.0" + }, + "provides": { + "mixins": "controller" + } +} diff --git a/vendor/wheels/tests/specs/ModuleGraphSpec.cfc b/vendor/wheels/tests/specs/ModuleGraphSpec.cfc new file mode 100644 index 000000000..232f5e5b3 --- /dev/null +++ b/vendor/wheels/tests/specs/ModuleGraphSpec.cfc @@ -0,0 +1,232 @@ +component extends="wheels.WheelsTest" { + + function run() { + + describe("ModuleGraph", () => { + + beforeEach(() => { + graph = new wheels.ModuleGraph(); + }); + + describe("resolve() with no dependencies", () => { + + it("returns all packages in load order when no requires/replaces/suggests", () => { + var manifests = { + "pkgA": {name: "wheels-pkgA", version: "1.0.0"}, + "pkgB": {name: "wheels-pkgB", version: "2.0.0"}, + "pkgC": {name: "wheels-pkgC", version: "1.5.0"} + }; + var result = graph.resolve(manifests); + + expect(ArrayLen(result.loadOrder)).toBe(3); + expect(StructIsEmpty(result.excluded)).toBeTrue(); + expect(ArrayLen(result.errors)).toBe(0); + }); + + it("returns empty results for empty manifests", () => { + var result = graph.resolve({}); + expect(ArrayLen(result.loadOrder)).toBe(0); + expect(ArrayLen(result.errors)).toBe(0); + }); + + }); + + describe("resolve() with requires", () => { + + it("orders dependencies before dependents", () => { + var manifests = { + "depA": { + name: "wheels-depA", version: "1.0.0", + requires: {"wheels-depB": ">=1.0.0"} + }, + "depB": {name: "wheels-depB", version: "2.0.0"} + }; + var result = graph.resolve(manifests); + + expect(ArrayLen(result.loadOrder)).toBe(2); + // depB must come before depA + var idxB = ArrayFind(result.loadOrder, "depB"); + var idxA = ArrayFind(result.loadOrder, "depA"); + expect(idxB).toBeLT(idxA); + }); + + it("reports error for missing required package", () => { + var manifests = { + "pkgA": { + name: "wheels-pkgA", version: "1.0.0", + requires: {"wheels-nonexistent": ">=1.0.0"} + } + }; + var result = graph.resolve(manifests); + + expect(ArrayLen(result.errors)).toBeGTE(1); + // pkgA should not be in load order since its dependency is missing + expect(ArrayFind(result.loadOrder, "pkgA")).toBe(0); + }); + + it("reports error for version mismatch", () => { + var manifests = { + "pkgA": { + name: "wheels-pkgA", version: "1.0.0", + requires: {"wheels-pkgB": ">=3.0.0"} + }, + "pkgB": {name: "wheels-pkgB", version: "2.0.0"} + }; + var result = graph.resolve(manifests); + + expect(ArrayLen(result.errors)).toBeGTE(1); + var foundVersionError = false; + for (var err in result.errors) { + if (Find("does not satisfy", err.message)) { + foundVersionError = true; + } + } + expect(foundVersionError).toBeTrue(); + }); + + it("handles transitive dependencies (A requires B requires C)", () => { + var manifests = { + "pkgA": { + name: "wheels-pkgA", version: "1.0.0", + requires: {"wheels-pkgB": ">=1.0.0"} + }, + "pkgB": { + name: "wheels-pkgB", version: "1.0.0", + requires: {"wheels-pkgC": ">=1.0.0"} + }, + "pkgC": {name: "wheels-pkgC", version: "1.0.0"} + }; + var result = graph.resolve(manifests); + + expect(ArrayLen(result.loadOrder)).toBe(3); + var idxC = ArrayFind(result.loadOrder, "pkgC"); + var idxB = ArrayFind(result.loadOrder, "pkgB"); + var idxA = ArrayFind(result.loadOrder, "pkgA"); + expect(idxC).toBeLT(idxB); + expect(idxB).toBeLT(idxA); + }); + + }); + + describe("resolve() with replaces", () => { + + it("excludes the replaced package from load order", () => { + var manifests = { + "original": {name: "wheels-original", version: "1.0.0"}, + "replacement": { + name: "wheels-replacement", version: "2.0.0", + replaces: {"wheels-original": "*"} + } + }; + var result = graph.resolve(manifests); + + expect(StructKeyExists(result.excluded, "original")).toBeTrue(); + expect(ArrayFind(result.loadOrder, "original")).toBe(0); + expect(ArrayFind(result.loadOrder, "replacement")).toBeGT(0); + }); + + it("respects version constraint on replacement", () => { + var manifests = { + "original": {name: "wheels-original", version: "3.0.0"}, + "replacement": { + name: "wheels-replacement", version: "2.0.0", + replaces: {"wheels-original": "<2.0.0"} + } + }; + var result = graph.resolve(manifests); + + // original v3.0.0 does NOT match <2.0.0, so it should NOT be replaced + expect(StructKeyExists(result.excluded, "original")).toBeFalse(); + expect(ArrayFind(result.loadOrder, "original")).toBeGT(0); + expect(ArrayFind(result.loadOrder, "replacement")).toBeGT(0); + }); + + }); + + describe("resolve() with suggests", () => { + + it("orders suggested package before the suggesting package", () => { + var manifests = { + "consumer": { + name: "wheels-consumer", version: "1.0.0", + suggests: {"wheels-provider": ">=1.0.0"} + }, + "provider": {name: "wheels-provider", version: "1.0.0"} + }; + var result = graph.resolve(manifests); + + expect(ArrayLen(result.loadOrder)).toBe(2); + var idxProvider = ArrayFind(result.loadOrder, "provider"); + var idxConsumer = ArrayFind(result.loadOrder, "consumer"); + expect(idxProvider).toBeLT(idxConsumer); + }); + + it("does not fail when suggested package is absent", () => { + var manifests = { + "consumer": { + name: "wheels-consumer", version: "1.0.0", + suggests: {"wheels-optional": ">=1.0.0"} + } + }; + var result = graph.resolve(manifests); + + expect(ArrayLen(result.loadOrder)).toBe(1); + expect(ArrayLen(result.errors)).toBe(0); + expect(result.loadOrder[1]).toBe("consumer"); + }); + + }); + + describe("resolve() cycle detection", () => { + + it("detects two-node circular dependency", () => { + var manifests = { + "cycleA": { + name: "wheels-cycleA", version: "1.0.0", + requires: {"wheels-cycleB": ">=1.0.0"} + }, + "cycleB": { + name: "wheels-cycleB", version: "1.0.0", + requires: {"wheels-cycleA": ">=1.0.0"} + } + }; + var result = graph.resolve(manifests); + + expect(ArrayLen(result.errors)).toBeGTE(1); + var foundCycleError = false; + for (var err in result.errors) { + if (Find("Circular dependency", err.message)) { + foundCycleError = true; + } + } + expect(foundCycleError).toBeTrue(); + }); + + it("does not include cycled packages in load order", () => { + var manifests = { + "cycleA": { + name: "wheels-cycleA", version: "1.0.0", + requires: {"wheels-cycleB": ">=1.0.0"} + }, + "cycleB": { + name: "wheels-cycleB", version: "1.0.0", + requires: {"wheels-cycleA": ">=1.0.0"} + }, + "standalone": {name: "wheels-standalone", version: "1.0.0"} + }; + var result = graph.resolve(manifests); + + // standalone should still be in load order + expect(ArrayFind(result.loadOrder, "standalone")).toBeGT(0); + // cycled packages should not be in load order + expect(ArrayFind(result.loadOrder, "cycleA")).toBe(0); + expect(ArrayFind(result.loadOrder, "cycleB")).toBe(0); + }); + + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/PackageLoaderSpec.cfc b/vendor/wheels/tests/specs/PackageLoaderSpec.cfc index 3f58309b7..23304ffd4 100644 --- a/vendor/wheels/tests/specs/PackageLoaderSpec.cfc +++ b/vendor/wheels/tests/specs/PackageLoaderSpec.cfc @@ -16,8 +16,11 @@ component extends="wheels.WheelsTest" { vendorPath = fixturesPath, componentPrefix = componentPrefix ); + // replacer replaces goodpkg, so check for replacer instead var pkgs = loader.getPackages(); - expect(pkgs).toHaveKey("goodpkg"); + expect(pkgs).toHaveKey("replacer"); + expect(pkgs).toHaveKey("depA"); + expect(pkgs).toHaveKey("depB"); }); it("skips directories without package.json", () => { @@ -58,8 +61,8 @@ component extends="wheels.WheelsTest" { var pkgs = loader.getPackages(); var failed = loader.getFailedPackages(); - // goodpkg should load, brokenpkg should fail - expect(pkgs).toHaveKey("goodpkg"); + // replacer should load (it replaces goodpkg), brokenpkg should fail + expect(pkgs).toHaveKey("replacer"); expect(pkgs).notToHaveKey("brokenpkg"); expect(ArrayLen(failed)).toBeGTE(1); @@ -81,9 +84,10 @@ component extends="wheels.WheelsTest" { componentPrefix = componentPrefix ); var meta = loader.getPackageMeta(); - expect(meta).toHaveKey("goodpkg"); - expect(meta.goodpkg.name).toBe("wheels-goodpkg"); - expect(meta.goodpkg.version).toBe("1.0.0"); + // replacer should have metadata (goodpkg is excluded from load but meta is only for loaded pkgs) + expect(meta).toHaveKey("replacer"); + expect(meta.replacer.name).toBe("wheels-replacer"); + expect(meta.replacer.version).toBe("2.0.0"); }); }); @@ -96,7 +100,8 @@ component extends="wheels.WheelsTest" { componentPrefix = componentPrefix ); var mixins = loader.getMixins(); - expect(mixins.controller).toHaveKey("$goodPkgTestHelper"); + // replacer provides controller mixins + expect(mixins.controller).toHaveKey("$replacerHelper"); }); it("does not inject into non-declared targets", () => { @@ -105,8 +110,8 @@ component extends="wheels.WheelsTest" { componentPrefix = componentPrefix ); var mixins = loader.getMixins(); - // goodpkg declares controller only, not model - expect(mixins.model).notToHaveKey("$goodPkgTestHelper"); + // depA declares controller only, not model + expect(mixins.model).notToHaveKey("$depAHelper"); }); it("skips mixins when provides.mixins is none", () => { @@ -131,6 +136,174 @@ component extends="wheels.WheelsTest" { }); + describe("Dependency ordering", () => { + + it("returns a load order array", () => { + var loader = new wheels.PackageLoader( + vendorPath = fixturesPath, + componentPrefix = componentPrefix + ); + var order = loader.getLoadOrder(); + expect(IsArray(order)).toBeTrue(); + expect(ArrayLen(order)).toBeGT(0); + }); + + it("loads depB before depA (depA requires depB)", () => { + var loader = new wheels.PackageLoader( + vendorPath = fixturesPath, + componentPrefix = componentPrefix + ); + var order = loader.getLoadOrder(); + var idxB = ArrayFind(order, "depB"); + var idxA = ArrayFind(order, "depA"); + + // Both should be in load order + expect(idxB).toBeGT(0); + expect(idxA).toBeGT(0); + // depB must load before depA + expect(idxB).toBeLT(idxA); + }); + + }); + + describe("Replacement", () => { + + it("excludes replaced packages", () => { + var loader = new wheels.PackageLoader( + vendorPath = fixturesPath, + componentPrefix = componentPrefix + ); + var excluded = loader.getExcludedPackages(); + + // replacer replaces goodpkg + expect(StructKeyExists(excluded, "goodpkg")).toBeTrue(); + }); + + it("does not load replaced packages", () => { + var loader = new wheels.PackageLoader( + vendorPath = fixturesPath, + componentPrefix = componentPrefix + ); + var pkgs = loader.getPackages(); + var order = loader.getLoadOrder(); + + // goodpkg is replaced, so it should not be in load order + expect(ArrayFind(order, "goodpkg")).toBe(0); + // replacer should be loaded + expect(pkgs).toHaveKey("replacer"); + }); + + }); + + describe("Cycle detection", () => { + + it("reports circular dependencies as failures", () => { + var loader = new wheels.PackageLoader( + vendorPath = fixturesPath, + componentPrefix = componentPrefix + ); + var failed = loader.getFailedPackages(); + + var foundCycleA = false; + var foundCycleB = false; + for (var f in failed) { + if (f.name == "cycleA" && Find("Circular dependency", f.error)) foundCycleA = true; + if (f.name == "cycleB" && Find("Circular dependency", f.error)) foundCycleB = true; + } + expect(foundCycleA).toBeTrue(); + expect(foundCycleB).toBeTrue(); + }); + + it("does not include cycled packages in load order", () => { + var loader = new wheels.PackageLoader( + vendorPath = fixturesPath, + componentPrefix = componentPrefix + ); + var order = loader.getLoadOrder(); + + expect(ArrayFind(order, "cycleA")).toBe(0); + expect(ArrayFind(order, "cycleB")).toBe(0); + }); + + }); + + describe("Missing requirements", () => { + + it("reports missing required packages as failures", () => { + var loader = new wheels.PackageLoader( + vendorPath = fixturesPath, + componentPrefix = componentPrefix + ); + var failed = loader.getFailedPackages(); + + var foundMissing = false; + for (var f in failed) { + if (f.name == "missingreq" && Find("not found", f.error)) foundMissing = true; + } + expect(foundMissing).toBeTrue(); + }); + + }); + + describe("Suggest ordering", () => { + + it("loads suggesting package even when suggested package is absent", () => { + var loader = new wheels.PackageLoader( + vendorPath = fixturesPath, + componentPrefix = componentPrefix + ); + var order = loader.getLoadOrder(); + + // suggestpkg suggests goodpkg, but goodpkg is replaced by replacer + // suggestpkg should still load (suggests are soft dependencies) + var idxSuggest = ArrayFind(order, "suggestpkg"); + expect(idxSuggest).toBeGT(0); + }); + + }); + + describe("Lazy loading", () => { + + it("does not eagerly instantiate lazy packages", () => { + var loader = new wheels.PackageLoader( + vendorPath = fixturesPath, + componentPrefix = componentPrefix + ); + var pkgs = loader.getPackages(); + + // lazypkg declares lazy=true and mixins=none, so it should NOT + // be in the eagerly-loaded packages struct yet + expect(pkgs).notToHaveKey("lazypkg"); + }); + + it("reports lazy packages as loaded via isPackageLoaded", () => { + var loader = new wheels.PackageLoader( + vendorPath = fixturesPath, + componentPrefix = componentPrefix + ); + + expect(loader.isPackageLoaded("lazypkg")).toBeTrue(); + }); + + it("instantiates lazy package on getPackage()", () => { + var loader = new wheels.PackageLoader( + vendorPath = fixturesPath, + componentPrefix = componentPrefix + ); + + // Should not be in packages yet + expect(loader.getPackages()).notToHaveKey("lazypkg"); + + // Accessing it triggers instantiation + var pkg = loader.getPackage("lazypkg"); + expect(pkg.initialized).toBeTrue(); + + // Now it should be in the packages struct + expect(loader.getPackages()).toHaveKey("lazypkg"); + }); + + }); + }); } diff --git a/vendor/wheels/tests/specs/semverSpec.cfc b/vendor/wheels/tests/specs/semverSpec.cfc index eb3ee2cab..334ed63ab 100644 --- a/vendor/wheels/tests/specs/semverSpec.cfc +++ b/vendor/wheels/tests/specs/semverSpec.cfc @@ -115,6 +115,12 @@ component extends="wheels.WheelsTest" { it("returns true for empty constraint", function() { expect(semver.satisfies("1.0.0", "")).toBeTrue() }) + + it("returns true for wildcard * constraint", function() { + expect(semver.satisfies("1.0.0", "*")).toBeTrue() + expect(semver.satisfies("0.0.1", "*")).toBeTrue() + expect(semver.satisfies("99.99.99", "*")).toBeTrue() + }) }) describe("SemVer caret (^) constraints", function() { @@ -180,6 +186,10 @@ component extends="wheels.WheelsTest" { it("returns true for empty constraint string", function() { expect(semver.satisfiesAll("1.0.0", "")).toBeTrue() }) + + it("returns true for wildcard * in satisfiesAll", function() { + expect(semver.satisfiesAll("5.0.0", "*")).toBeTrue() + }) }) describe("SemVer format", function() {