diff --git a/packages/legacyadapter/DeprecationLogger.cfc b/packages/legacyadapter/DeprecationLogger.cfc new file mode 100644 index 000000000..b6c3215e7 --- /dev/null +++ b/packages/legacyadapter/DeprecationLogger.cfc @@ -0,0 +1,131 @@ +/** + * Centralized deprecation logging for the Wheels legacy adapter. + * + * Tracks deprecated API usage with configurable severity levels. + * Deduplicates warnings per-request to avoid log spam. + * + * Modes: + * silent — no output (adapter installed but quiet) + * log — WriteLog only (default) + * warn — WriteLog + stores in request scope for debug panel + * error — throws an exception (use during Stage 3 to find stragglers) + */ +component output="false" { + + /** + * Initialize the deprecation logger. + * + * @mode Logging mode: silent, log, warn, or error + */ + public any function init(string mode = "log") { + variables.mode = arguments.mode; + return this; + } + + /** + * Returns the current logging mode. + */ + public string function getMode() { + return variables.mode; + } + + /** + * Sets the logging mode at runtime. + * + * @mode Logging mode: silent, log, warn, or error + */ + public void function setMode(required string mode) { + if (!ListFindNoCase("silent,log,warn,error", arguments.mode)) { + Throw( + type = "Wheels.LegacyAdapter.InvalidMode", + message = "Invalid deprecation logger mode: '#arguments.mode#'. Valid modes: silent, log, warn, error." + ); + } + variables.mode = arguments.mode; + } + + /** + * Log a deprecation warning. + * + * @oldMethod The deprecated method or pattern name + * @newMethod The replacement method or pattern + * @message Additional migration guidance + */ + public void function logDeprecation( + required string oldMethod, + required string newMethod, + string message = "" + ) { + if (variables.mode == "silent") { + return; + } + + var key = arguments.oldMethod & "->" & arguments.newMethod; + + /* deduplicate within the current request */ + $ensureRequestScope(); + if (StructKeyExists(request.wheels.deprecations.seen, key)) { + return; + } + request.wheels.deprecations.seen[key] = true; + + var logText = "[Wheels Legacy Adapter] '#arguments.oldMethod#' is deprecated. Use '#arguments.newMethod#' instead."; + if (Len(arguments.message)) { + logText = logText & " " & arguments.message; + } + + /* record for debug panel */ + var entry = { + oldMethod: arguments.oldMethod, + newMethod: arguments.newMethod, + message: arguments.message, + timestamp: Now() + }; + ArrayAppend(request.wheels.deprecations.entries, entry); + + if (variables.mode == "error") { + Throw( + type = "Wheels.LegacyAdapter.DeprecatedAPI", + message = logText + ); + } + + WriteLog(type = "warning", text = logText); + } + + /** + * Returns all deprecation entries logged in the current request. + */ + public array function getRequestDeprecations() { + $ensureRequestScope(); + return request.wheels.deprecations.entries; + } + + /** + * Returns the count of unique deprecations logged in the current request. + */ + public numeric function getRequestDeprecationCount() { + $ensureRequestScope(); + return ArrayLen(request.wheels.deprecations.entries); + } + + /** + * Resets the per-request deprecation tracking. + */ + public void function resetRequestDeprecations() { + request.wheels.deprecations = {seen: {}, entries: []}; + } + + /** + * Ensures the request-scope struct exists for deprecation tracking. + */ + public void function $ensureRequestScope() { + if (!StructKeyExists(request, "wheels")) { + request.wheels = {}; + } + if (!StructKeyExists(request.wheels, "deprecations")) { + request.wheels.deprecations = {seen: {}, entries: []}; + } + } + +} diff --git a/packages/legacyadapter/LegacyAdapter.cfc b/packages/legacyadapter/LegacyAdapter.cfc new file mode 100644 index 000000000..2be673d66 --- /dev/null +++ b/packages/legacyadapter/LegacyAdapter.cfc @@ -0,0 +1,219 @@ +/** + * wheels-legacy-adapter — Backward compatibility for Wheels 3.x applications. + * + * Provides deprecated API shims that delegate to current 4.0 implementations + * while logging deprecation warnings. Install this package to ease migration + * from 3.x to 4.0. + * + * Migration stages: + * Stage 1: Install adapter — existing code works unchanged + * Stage 2: Use migration scanner, update code incrementally + * Stage 3: Remove adapter when all legacy patterns eliminated + * + * Configuration (in config/settings.cfm): + * set(legacyAdapterMode = "log") — silent, log, warn, or error + */ +component mixin="controller" output="false" { + + function init() { + $initLegacyAdapter(); + return this; + } + + /** + * Initialize the deprecation logger instance. + * Reads mode from Wheels settings if available, falls back to "log". + * Reads version from package.json. + */ + public void function $initLegacyAdapter() { + var mode = "log"; + try { + mode = get("legacyAdapterMode"); + } catch (any e) { + /* setting not configured — use default */ + } + variables.$legacyAdapterLogger = new DeprecationLogger(mode = mode); + + /* read version from package.json */ + variables.$legacyAdapterVersionString = "0.0.0"; + try { + var packageDir = GetDirectoryFromPath(GetCurrentTemplatePath()); + var packageJsonPath = packageDir & "package.json"; + if (FileExists(packageJsonPath)) { + var manifest = DeserializeJSON(FileRead(packageJsonPath)); + if (StructKeyExists(manifest, "version")) { + variables.$legacyAdapterVersionString = manifest.version; + } + } + } catch (any e) { + /* fallback to default if package.json is unreadable */ + } + } + + /** + * Returns the adapter version string (sourced from package.json). + */ + public string function $legacyAdapterVersion() { + if (!StructKeyExists(variables, "$legacyAdapterVersionString")) { + $initLegacyAdapter(); + } + return variables.$legacyAdapterVersionString; + } + + /** + * Returns a summary of the adapter status and any deprecations in this request. + */ + public struct function $legacyAdapterStatus() { + var logger = $getLegacyLogger(); + return { + version: $legacyAdapterVersion(), + mode: logger.getMode(), + deprecationsThisRequest: logger.getRequestDeprecationCount(), + entries: logger.getRequestDeprecations() + }; + } + + /* ------------------------------------------------------------------ */ + /* Controller Shims */ + /* ------------------------------------------------------------------ */ + + /** + * DEPRECATED: Use renderView() instead. + * + * Legacy shim for Wheels 1.x/2.x renderPage() method. + * Delegates to renderView() with all arguments passed through. + */ + public any function renderPage() { + $getLegacyLogger().logDeprecation( + oldMethod = "renderPage()", + newMethod = "renderView()", + message = "renderPage() was renamed in Wheels 3.0. Update your controller actions." + ); + return renderView(argumentCollection = arguments); + } + + /** + * DEPRECATED: Use renderView(returnAs="string") instead. + * + * Legacy shim for Wheels 1.x/2.x renderPageToString() method. + */ + public string function renderPageToString() { + $getLegacyLogger().logDeprecation( + oldMethod = "renderPageToString()", + newMethod = "renderView(returnAs=""string"")", + message = "renderPageToString() was removed in Wheels 3.0. Use renderView(returnAs=""string"") instead." + ); + arguments.returnAs = "string"; + return renderView(argumentCollection = arguments); + } + + /** + * DEPRECATED: Use sendEmail() with updated argument names. + * + * Legacy shim that maps old sendEmail argument names to current ones. + * In Wheels 2.x, the layout argument defaulted differently. + */ + public any function $legacySendEmail() { + $getLegacyLogger().logDeprecation( + oldMethod = "$legacySendEmail()", + newMethod = "sendEmail()", + message = "Use the standard sendEmail() function directly." + ); + return sendEmail(argumentCollection = arguments); + } + + /* ------------------------------------------------------------------ */ + /* Configuration Shims */ + /* ------------------------------------------------------------------ */ + + /** + * DEPRECATED: Use the DI container via service() and injector() instead. + * + * Returns a value from the application.wheels struct, which was the + * pre-4.0 way to access framework internals. Logs deprecation. + * + * @key The application.wheels key to read + */ + public any function $legacyAppScopeGet(required string key) { + $getLegacyLogger().logDeprecation( + oldMethod = "application.wheels.#arguments.key#", + newMethod = "service() or injector()", + message = "Direct application.wheels access is discouraged. Use the DI container for service resolution." + ); + var appKey = "$wheels"; + if (StructKeyExists(application, "wheels")) { + appKey = "wheels"; + } + if (StructKeyExists(application[appKey], arguments.key)) { + return application[appKey][arguments.key]; + } + Throw( + type = "Wheels.LegacyAdapter.KeyNotFound", + message = "Key '#arguments.key#' not found in application scope." + ); + } + + /* ------------------------------------------------------------------ */ + /* Plugin Diagnostics */ + /* ------------------------------------------------------------------ */ + + /** + * Checks whether legacy plugins are loaded and returns info about them. + * Useful during migration to identify plugins that need conversion to packages. + * + * This is a diagnostic function — it reports what legacy plugins exist but + * does not perform automatic wrapping or bridging. The actual migration + * from plugin to package is a manual process guided by the scanner report. + */ + public struct function $legacyPluginInfo() { + var info = {plugins: [], hasLegacyPlugins: false}; + var appKey = "$wheels"; + if (StructKeyExists(application, "wheels")) { + appKey = "wheels"; + } + if (StructKeyExists(application[appKey], "plugins")) { + var pluginStruct = application[appKey].plugins; + info.hasLegacyPlugins = !StructIsEmpty(pluginStruct); + for (var key in pluginStruct) { + ArrayAppend(info.plugins, { + name: key, + version: StructKeyExists(pluginStruct[key], "version") ? pluginStruct[key].version : "unknown" + }); + } + } + return info; + } + + /* ------------------------------------------------------------------ */ + /* Migration Scanner Access */ + /* ------------------------------------------------------------------ */ + + /** + * Runs the migration scanner against the application directory. + * Returns a structured report of legacy patterns found. + * + * @appPath Path to scan (defaults to the app/ directory) + */ + public struct function $runMigrationScan(string appPath = "") { + if (!Len(arguments.appPath)) { + arguments.appPath = ExpandPath("/app"); + } + var scanner = new MigrationScanner(); + return scanner.scan(appPath = arguments.appPath); + } + + /* ------------------------------------------------------------------ */ + /* Internal Helpers */ + /* ------------------------------------------------------------------ */ + + /** + * Returns the deprecation logger, initializing if needed. + */ + public any function $getLegacyLogger() { + if (!StructKeyExists(variables, "$legacyAdapterLogger")) { + $initLegacyAdapter(); + } + return variables.$legacyAdapterLogger; + } + +} diff --git a/packages/legacyadapter/MigrationScanner.cfc b/packages/legacyadapter/MigrationScanner.cfc new file mode 100644 index 000000000..15a15aff4 --- /dev/null +++ b/packages/legacyadapter/MigrationScanner.cfc @@ -0,0 +1,231 @@ +/** + * Analyzes application source files for legacy Wheels patterns. + * + * Scans CFML files (`.cfc`, `.cfm`) for patterns that should be updated + * when migrating from Wheels 3.x to 4.0. Returns a structured report + * with file, line, pattern name, severity, and migration guidance. + * + * Severity levels: + * info — optional improvement, old way still works + * warning — will be deprecated in a future release + * critical — already deprecated, will break in next major version + */ +component output="false" { + + public any function init() { + variables.patterns = $buildPatternList(); + return this; + } + + /** + * Scan a directory for legacy patterns. + * + * @appPath Absolute path to the directory to scan + * @recursive Whether to scan subdirectories (default true) + */ + public struct function scan(required string appPath, boolean recursive = true) { + var report = { + scannedAt: Now(), + appPath: arguments.appPath, + totalFiles: 0, + totalFindings: 0, + findings: [], + summary: {} + }; + + if (!DirectoryExists(arguments.appPath)) { + report.error = "Directory not found: #arguments.appPath#"; + return report; + } + + var files = DirectoryList( + arguments.appPath, + arguments.recursive, + "path", + "*.cfc|*.cfm" + ); + + report.totalFiles = ArrayLen(files); + + for (var filePath in files) { + $scanFile(filePath = filePath, report = report); + } + + report.totalFindings = ArrayLen(report.findings); + report.summary = $buildSummary(report.findings); + + return report; + } + + /** + * Scan a single file for legacy patterns. + */ + public void function $scanFile(required string filePath, required struct report) { + var content = ""; + try { + content = FileRead(arguments.filePath); + } catch (any e) { + return; + } + + if (!Len(Trim(content))) { + return; + } + + /* skip test/fixture directories entirely */ + if ($isTestPath(arguments.filePath)) { + return; + } + + var normalizedPath = ReplaceNoCase(arguments.filePath, "\", "/", "all"); + var lines = ListToArray(content, Chr(10), true); + var lineCount = ArrayLen(lines); + + for (var i = 1; i <= lineCount; i++) { + var line = lines[i]; + for (var pattern in variables.patterns) { + if (REFindNoCase(pattern.regex, line)) { + /* apply path filter if pattern requires one */ + if (StructKeyExists(pattern, "pathFilter") && Len(pattern.pathFilter)) { + if (FindNoCase(pattern.pathFilter, normalizedPath) == 0) { + continue; + } + } + ArrayAppend(arguments.report.findings, { + file: arguments.filePath, + line: i, + lineContent: Trim(line), + pattern: pattern.name, + severity: pattern.severity, + guidance: pattern.guidance + }); + } + } + } + } + + /** + * Determines if a file path is in a test or fixture directory. + */ + public boolean function $isTestPath(required string filePath) { + var normalized = ReplaceNoCase(arguments.filePath, "\", "/", "all"); + return ( + FindNoCase("/tests/", normalized) > 0 + || FindNoCase("/test/", normalized) > 0 + || FindNoCase("/_assets/", normalized) > 0 + || FindNoCase("/fixtures/", normalized) > 0 + ); + } + + /** + * Builds the list of patterns to scan for. + * + * Each pattern is a struct with keys: name, regex, severity, guidance. + * Patterns may also include a `pathFilter` key — if present, the pattern + * only matches files whose path contains that substring. This prevents + * false positives (e.g., `this.version` in non-plugin CFCs). + */ + public array function $buildPatternList() { + var p = []; + + /* ---- Controller patterns ---- */ + + ArrayAppend(p, { + name: "renderPage", + regex: "renderPage\s*\(", + severity: "critical", + guidance: "Replace renderPage() with renderView(). The method was renamed in Wheels 3.0." + }); + + ArrayAppend(p, { + name: "renderPageToString", + regex: "renderPageToString\s*\(", + severity: "critical", + guidance: "Replace renderPageToString() with renderView(returnAs=""string"")." + }); + + /* ---- Plugin patterns (restricted to plugins/ directory) ---- */ + + ArrayAppend(p, { + name: "legacyPluginVersion", + regex: "this\.version\s*=", + severity: "warning", + pathFilter: "/plugins/", + guidance: "Legacy plugin version declaration. Move to package.json manifest with 'version' field. See: https://wheels.dev/docs/packages" + }); + + ArrayAppend(p, { + name: "legacyPluginDependency", + regex: "this\.dependency\s*=", + severity: "warning", + pathFilter: "/plugins/", + guidance: "Legacy plugin dependency declaration. Move to package.json 'dependencies' field." + }); + + /* ---- Application scope direct access ---- */ + + ArrayAppend(p, { + name: "directAppScopeAccess", + regex: "application\.(wheels|\$wheels)\.\w+", + severity: "info", + guidance: "Direct application scope access is discouraged in 4.0. Use service() or injector() for DI, or get()/set() for framework settings." + }); + + /* ---- Old extends patterns (future-proofing) ---- */ + + ArrayAppend(p, { + name: "shortExtendsModel", + regex: 'extends\s*=\s*"Model"', + severity: "info", + guidance: "Short extends=""Model"" still works in 4.0. Future releases may require the full path extends=""wheels.Model"". No action needed yet." + }); + + ArrayAppend(p, { + name: "shortExtendsController", + regex: 'extends\s*=\s*"Controller"', + severity: "info", + guidance: "Short extends=""Controller"" still works in 4.0. Future releases may require the full path extends=""wheels.Controller"". No action needed yet." + }); + + /* ---- Query patterns (informational) ---- */ + + ArrayAppend(p, { + name: "rawWhereString", + regex: "findAll\s*\([^)]*where\s*=\s*""[^""]*=[^""]*""", + severity: "info", + guidance: "String-based WHERE clauses still work but the chainable query builder (.where().get()) provides better injection safety. Consider migrating." + }); + + /* ---- Old test extends ---- */ + + ArrayAppend(p, { + name: "legacyTestExtends", + regex: 'extends\s*=\s*"wheels\.Test"', + severity: "warning", + guidance: "extends=""wheels.Test"" (RocketUnit) is deprecated. Use extends=""wheels.WheelsTest"" (TestBox) for new tests." + }); + + return p; + } + + /** + * Builds a count-by-severity and count-by-pattern summary. + */ + public struct function $buildSummary(required array findings) { + var summary = { + bySeverity: {info: 0, warning: 0, critical: 0}, + byPattern: {} + }; + for (var finding in arguments.findings) { + if (StructKeyExists(summary.bySeverity, finding.severity)) { + summary.bySeverity[finding.severity] = summary.bySeverity[finding.severity] + 1; + } + if (!StructKeyExists(summary.byPattern, finding.pattern)) { + summary.byPattern[finding.pattern] = 0; + } + summary.byPattern[finding.pattern] = summary.byPattern[finding.pattern] + 1; + } + return summary; + } + +} diff --git a/packages/legacyadapter/README.md b/packages/legacyadapter/README.md new file mode 100644 index 000000000..062b67a50 --- /dev/null +++ b/packages/legacyadapter/README.md @@ -0,0 +1,96 @@ +# wheels-legacyadapter + +Backward compatibility adapter for migrating Wheels 3.x applications to 4.0. Provides deprecation logging, API shims, and a migration scanner for a smooth, progressive upgrade path. + +## Quick Start + +```bash +# Activate the adapter +cp -r packages/legacyadapter vendor/legacyadapter + +# Restart or reload your app +``` + +That's it. Your 3.x code continues to work unchanged. + +## Migration Stages + +### Stage 1: Install & Go + +Install the adapter. All existing 3.x code works without modification. Deprecation warnings are logged whenever legacy patterns are used, helping you identify what needs updating. + +### Stage 2: Migrate + +Run the migration scanner to get a full report of legacy patterns in your application: + +```cfml +// In a controller action or script +var report = $runMigrationScan(); +WriteDump(report); +``` + +Update code incrementally. The adapter provides dual-mode support — both old and new APIs work simultaneously. + +Increase visibility by changing the mode: + +```cfml +// config/settings.cfm +set(legacyAdapterMode = "warn"); +``` + +### Stage 3: Remove + +Set mode to `error` to catch any remaining legacy calls: + +```cfml +set(legacyAdapterMode = "error"); +``` + +Once your app runs cleanly with no deprecation errors, remove the adapter: + +```bash +rm -rf vendor/legacyadapter +``` + +## Configuration + +| Setting | Values | Default | Description | +|---------|--------|---------|-------------| +| `legacyAdapterMode` | `silent`, `log`, `warn`, `error` | `log` | Controls deprecation logging behavior | + +## Compatibility Shims + +| Legacy Method | Replacement | Notes | +|---|---|---| +| `renderPage()` | `renderView()` | Renamed in Wheels 3.0 | +| `renderPageToString()` | `renderView(returnAs="string")` | Removed in Wheels 3.0 | + +## Plugin Diagnostics + +The adapter includes `$legacyPluginInfo()` — a diagnostic function that identifies legacy plugins still active in the `plugins/` directory. It reports plugin names and versions to help you inventory what needs conversion to the modern package system. The actual migration from plugin to package is a manual process. + +## Migration Scanner Patterns + +The scanner detects these patterns and provides guidance: + +| Pattern | Severity | Scope | Description | +|---------|----------|-------|-------------| +| `renderPage()` | Critical | All files | Must be replaced | +| `renderPageToString()` | Critical | All files | Must be replaced | +| `this.version =` | Warning | `plugins/` only | Plugin version — move to package.json | +| `this.dependency =` | Warning | `plugins/` only | Plugin deps — move to package.json | +| `extends="wheels.Test"` | Warning | All files | Use `wheels.WheelsTest` for TestBox | +| `application.wheels.*` access | Info | All files | Consider DI container | +| `extends="Model"` (short) | Info | All files | Future-proof to full path | +| `extends="Controller"` (short) | Info | All files | Future-proof to full path | +| Raw WHERE strings | Info | All files | Consider query builder | + +Note: `this.version` and `this.dependency` are only flagged in files within the `plugins/` directory to avoid false positives on model CFCs, services, or other components that legitimately use version properties. + +## Deactivating + +```bash +rm -rf vendor/legacyadapter +``` + +No other changes needed. The adapter is purely additive. diff --git a/packages/legacyadapter/box.json b/packages/legacyadapter/box.json new file mode 100644 index 000000000..8cd51da28 --- /dev/null +++ b/packages/legacyadapter/box.json @@ -0,0 +1,10 @@ +{ + "name": "wheels-legacy-adapter", + "version": "1.0.0", + "type": "cfwheels-plugins", + "homepage": "https://wheels.dev", + "repository": { + "type": "git", + "URL": "https://github.com/wheels-dev/wheels" + } +} diff --git a/packages/legacyadapter/index.cfm b/packages/legacyadapter/index.cfm new file mode 100644 index 000000000..d6359bd77 --- /dev/null +++ b/packages/legacyadapter/index.cfm @@ -0,0 +1,77 @@ + + + + + + +

Wheels Legacy Adapter v#status.version#

+ +

Status

+ + + + + + + + + + + + + +
Mode#status.mode#
Deprecations (this request)#status.deprecationsThisRequest#
Legacy Plugins Active#YesNoFormat(pluginInfo.hasLegacyPlugins)#
+ + +

Legacy Plugins Found

+

These plugins should be migrated to the package system:

+ + + + + + + + + + + + +
PluginVersion
#p.name##p.version#
+
+ + +

Deprecation Warnings (this request)

+ + + + + + + + + + + + + +
OldNewGuidance
#entry.oldMethod##entry.newMethod##entry.message#
+
+ +

Migration Guide

+

The legacy adapter supports a three-stage migration path:

+
    +
  1. Stage 1 (Install & Go): Copy packages/legacyadapter to vendor/legacyadapter. All 3.x code works unchanged. Deprecation warnings appear in logs.
  2. +
  3. Stage 2 (Migrate): Run the migration scanner to find legacy patterns. Update code incrementally. Set legacyAdapterMode to "warn" for more visibility.
  4. +
  5. Stage 3 (Remove): Set mode to "error" to catch any remaining legacy calls. Once clean, remove vendor/legacyadapter.
  6. +
+ +

Configuration

+
// config/settings.cfm
+set(legacyAdapterMode = "log");  // silent, log, warn, or error
+ +

Running the Scanner

+
// In a controller action or script
+var report = $runMigrationScan();
+WriteDump(report);
+
diff --git a/packages/legacyadapter/package.json b/packages/legacyadapter/package.json new file mode 100644 index 000000000..f354a12e4 --- /dev/null +++ b/packages/legacyadapter/package.json @@ -0,0 +1,13 @@ +{ + "name": "wheels-legacy-adapter", + "version": "1.0.0", + "author": "Wheels Team", + "description": "Backward compatibility adapter for migrating Wheels 3.x applications to 4.0. Provides deprecation logging, API shims, and a migration scanner.", + "wheelsVersion": ">=3.0", + "provides": { + "mixins": "controller", + "services": [], + "middleware": [] + }, + "dependencies": {} +} diff --git a/packages/legacyadapter/tests/LegacyAdapterSpec.cfc b/packages/legacyadapter/tests/LegacyAdapterSpec.cfc new file mode 100644 index 000000000..ece978da8 --- /dev/null +++ b/packages/legacyadapter/tests/LegacyAdapterSpec.cfc @@ -0,0 +1,358 @@ +/** + * wheels-legacy-adapter — TestBox BDD specs + * + * Tests the three core components: + * 1. DeprecationLogger — mode behavior, deduplication, request tracking + * 2. LegacyAdapter — shim method delegation and deprecation logging + * 3. MigrationScanner — pattern detection in CFML source files + */ +component extends="wheels.WheelsTest" output="false" { + + function run() { + + /* ============================================================ */ + /* DeprecationLogger */ + /* ============================================================ */ + + describe("DeprecationLogger", () => { + + beforeEach(() => { + /* clean request scope before each test */ + if (StructKeyExists(request, "wheels") && StructKeyExists(request.wheels, "deprecations")) { + StructDelete(request.wheels, "deprecations"); + } + }); + + it("initializes with default mode 'log'", () => { + var logger = $createLogger(); + expect(logger.getMode()).toBe("log"); + }); + + it("accepts a custom mode on init", () => { + var logger = $createLogger("warn"); + expect(logger.getMode()).toBe("warn"); + }); + + it("rejects invalid modes", () => { + var logger = $createLogger(); + var threw = false; + try { + logger.setMode("banana"); + } catch (any e) { + threw = true; + } + expect(threw).toBeTrue("setMode should throw for invalid mode"); + }); + + it("logs nothing in silent mode", () => { + var logger = $createLogger("silent"); + logger.logDeprecation( + oldMethod = "oldFunc()", + newMethod = "newFunc()" + ); + expect(logger.getRequestDeprecationCount()).toBe(0); + }); + + it("logs entries in log mode", () => { + var logger = $createLogger("log"); + logger.logDeprecation( + oldMethod = "renderPage()", + newMethod = "renderView()" + ); + expect(logger.getRequestDeprecationCount()).toBe(1); + }); + + it("deduplicates within same request", () => { + var logger = $createLogger("log"); + logger.logDeprecation( + oldMethod = "renderPage()", + newMethod = "renderView()" + ); + logger.logDeprecation( + oldMethod = "renderPage()", + newMethod = "renderView()" + ); + expect(logger.getRequestDeprecationCount()).toBe(1); + }); + + it("tracks distinct deprecations separately", () => { + var logger = $createLogger("log"); + logger.logDeprecation( + oldMethod = "renderPage()", + newMethod = "renderView()" + ); + logger.logDeprecation( + oldMethod = "renderPageToString()", + newMethod = "renderView(returnAs='string')" + ); + expect(logger.getRequestDeprecationCount()).toBe(2); + }); + + it("throws in error mode", () => { + var logger = $createLogger("error"); + var threw = false; + try { + logger.logDeprecation( + oldMethod = "renderPage()", + newMethod = "renderView()" + ); + } catch (Wheels.LegacyAdapter.DeprecatedAPI e) { + threw = true; + } + expect(threw).toBeTrue("error mode should throw DeprecatedAPI exception"); + }); + + it("returns entries with correct structure", () => { + var logger = $createLogger("warn"); + logger.logDeprecation( + oldMethod = "renderPage()", + newMethod = "renderView()", + message = "Renamed in 3.0" + ); + var entries = logger.getRequestDeprecations(); + expect(ArrayLen(entries)).toBe(1); + expect(entries[1].oldMethod).toBe("renderPage()"); + expect(entries[1].newMethod).toBe("renderView()"); + expect(entries[1].message).toBe("Renamed in 3.0"); + expect(StructKeyExists(entries[1], "timestamp")).toBeTrue(); + }); + + it("resets request deprecations cleanly", () => { + var logger = $createLogger("log"); + logger.logDeprecation( + oldMethod = "renderPage()", + newMethod = "renderView()" + ); + logger.resetRequestDeprecations(); + expect(logger.getRequestDeprecationCount()).toBe(0); + }); + + }); + + /* ============================================================ */ + /* LegacyAdapter */ + /* ============================================================ */ + + describe("LegacyAdapter", () => { + + it("initializes without error", () => { + var threw = false; + try { + var adapter = $createAdapter(); + } catch (any e) { + threw = true; + } + expect(threw).toBeFalse("LegacyAdapter init should not throw"); + }); + + it("returns version string from package.json", () => { + var adapter = $createAdapter(); + var version = adapter.$legacyAdapterVersion(); + /* version should be a valid semver-like string, not the old hardcoded fallback */ + expect(Len(version) > 0).toBeTrue("version should not be empty"); + expect(FindNoCase(".", version) > 0).toBeTrue("version should contain a dot (semver)"); + }); + + it("returns status struct with required keys", () => { + var adapter = $createAdapter(); + var status = adapter.$legacyAdapterStatus(); + expect(StructKeyExists(status, "version")).toBeTrue(); + expect(StructKeyExists(status, "mode")).toBeTrue(); + expect(StructKeyExists(status, "deprecationsThisRequest")).toBeTrue(); + expect(StructKeyExists(status, "entries")).toBeTrue(); + }); + + it("returns plugin info struct", () => { + var adapter = $createAdapter(); + var info = adapter.$legacyPluginInfo(); + expect(StructKeyExists(info, "plugins")).toBeTrue(); + expect(StructKeyExists(info, "hasLegacyPlugins")).toBeTrue(); + expect(IsArray(info.plugins)).toBeTrue(); + }); + + }); + + /* ============================================================ */ + /* MigrationScanner */ + /* ============================================================ */ + + describe("MigrationScanner", () => { + + it("initializes without error", () => { + var threw = false; + try { + var scanner = $createScanner(); + } catch (any e) { + threw = true; + } + expect(threw).toBeFalse("MigrationScanner init should not throw"); + }); + + it("returns error for non-existent directory", () => { + var scanner = $createScanner(); + var report = scanner.scan(appPath = "/tmp/nonexistent-wheels-test-dir-#CreateUUID()#"); + expect(StructKeyExists(report, "error")).toBeTrue(); + }); + + it("returns report struct with required keys", () => { + var scanner = $createScanner(); + /* scan the adapter's own directory (small, known content) */ + var report = scanner.scan(appPath = ExpandPath("/wheels/tests/_assets")); + expect(StructKeyExists(report, "scannedAt")).toBeTrue(); + expect(StructKeyExists(report, "totalFiles")).toBeTrue(); + expect(StructKeyExists(report, "totalFindings")).toBeTrue(); + expect(StructKeyExists(report, "findings")).toBeTrue(); + expect(StructKeyExists(report, "summary")).toBeTrue(); + expect(IsArray(report.findings)).toBeTrue(); + }); + + it("detects renderPage pattern in source", () => { + var scanner = $createScanner(); + var result = $scanContent(scanner, 'renderPage(template="home/index")', "app/controllers"); + expect(result.found).toBeTrue("Scanner should detect renderPage() call"); + expect(result.pattern).toBe("renderPage"); + }); + + it("detects renderPageToString pattern", () => { + var scanner = $createScanner(); + var result = $scanContent(scanner, 'var html = renderPageToString(action="show")', "app/controllers"); + expect(result.found).toBeTrue("Scanner should detect renderPageToString() call"); + expect(result.pattern).toBe("renderPageToString"); + }); + + it("detects legacy plugin version declaration in plugins directory", () => { + var scanner = $createScanner(); + var result = $scanContent(scanner, 'this.version = "1.0.0";', "plugins/MyPlugin"); + expect(result.found).toBeTrue("Scanner should detect this.version = in plugins/ dir"); + expect(result.pattern).toBe("legacyPluginVersion"); + }); + + it("does NOT flag this.version outside plugins directory", () => { + var scanner = $createScanner(); + var result = $scanContent(scanner, 'this.version = "1.0.0";', "app/models"); + expect(result.found).toBeFalse("Scanner should NOT flag this.version in app/models/"); + }); + + it("detects legacy plugin dependency declaration in plugins directory", () => { + var scanner = $createScanner(); + var result = $scanContent(scanner, 'this.dependency = "PluginA,PluginB";', "plugins/MyPlugin"); + expect(result.found).toBeTrue("Scanner should detect this.dependency = in plugins/ dir"); + expect(result.pattern).toBe("legacyPluginDependency"); + }); + + it("does NOT flag this.dependency outside plugins directory", () => { + var scanner = $createScanner(); + var result = $scanContent(scanner, 'this.dependency = "SomeLib";', "app/lib"); + expect(result.found).toBeFalse("Scanner should NOT flag this.dependency in app/lib/"); + }); + + it("detects legacy test extends", () => { + var scanner = $createScanner(); + var result = $scanContent(scanner, 'component extends="wheels.Test" {', "app/controllers"); + expect(result.found).toBeTrue("Scanner should detect extends=""wheels.Test"""); + expect(result.pattern).toBe("legacyTestExtends"); + }); + + it("detects direct application scope access", () => { + var scanner = $createScanner(); + var result = $scanContent(scanner, 'var env = application.wheels.environment;', "app/controllers"); + expect(result.found).toBeTrue("Scanner should detect application.wheels.* access"); + expect(result.pattern).toBe("directAppScopeAccess"); + }); + + it("skips test directory files", () => { + var scanner = $createScanner(); + var isTest = scanner.$isTestPath("/app/tests/specs/MySpec.cfc"); + expect(isTest).toBeTrue("paths containing /tests/ should be flagged as test paths"); + }); + + it("does not flag non-test paths as test paths", () => { + var scanner = $createScanner(); + var isTest = scanner.$isTestPath("/app/controllers/Users.cfc"); + expect(isTest).toBeFalse("controller paths should not be flagged as test paths"); + }); + + it("builds correct summary by severity", () => { + var scanner = $createScanner(); + var findings = [ + {severity: "critical", pattern: "renderPage"}, + {severity: "critical", pattern: "renderPage"}, + {severity: "warning", pattern: "legacyPluginVersion"}, + {severity: "info", pattern: "shortExtendsModel"} + ]; + var summary = scanner.$buildSummary(findings); + expect(summary.bySeverity.critical).toBe(2); + expect(summary.bySeverity.warning).toBe(1); + expect(summary.bySeverity.info).toBe(1); + }); + + it("builds correct summary by pattern", () => { + var scanner = $createScanner(); + var findings = [ + {severity: "critical", pattern: "renderPage"}, + {severity: "critical", pattern: "renderPage"}, + {severity: "warning", pattern: "legacyPluginVersion"} + ]; + var summary = scanner.$buildSummary(findings); + expect(summary.byPattern.renderPage).toBe(2); + expect(summary.byPattern.legacyPluginVersion).toBe(1); + }); + + }); + + } + + /* ================================================================ */ + /* Test Helpers */ + /* ================================================================ */ + + /** + * Creates a DeprecationLogger with the given mode. + */ + private any function $createLogger(string mode = "log") { + return new vendor.legacyadapter.DeprecationLogger(mode = arguments.mode); + } + + /** + * Creates a LegacyAdapter instance. + */ + private any function $createAdapter() { + return new vendor.legacyadapter.LegacyAdapter(); + } + + /** + * Creates a MigrationScanner instance. + */ + private any function $createScanner() { + return new vendor.legacyadapter.MigrationScanner(); + } + + /** + * Scans a single line of content for patterns. + * Returns {found: boolean, pattern: string}. + * + * @scanner The scanner instance + * @content The source content to scan + * @subDir Subdirectory within temp dir to simulate file location (e.g. "plugins/MyPlugin" or "app/models") + */ + private struct function $scanContent(required any scanner, required string content, string subDir = "app") { + /* Write content to a temp file, scan it, return first finding */ + var tempBase = GetTempDirectory() & "wheels-legacy-test-#CreateUUID()#"; + var tempDir = tempBase & "/" & arguments.subDir; + CreateObject("java", "java.io.File").init(tempDir).mkdirs(); + var tempFile = tempDir & "/test.cfm"; + FileWrite(tempFile, arguments.content); + + var report = arguments.scanner.scan(appPath = tempBase); + + /* clean up */ + FileDelete(tempFile); + DirectoryDelete(tempBase, true); + + if (ArrayLen(report.findings) > 0) { + return {found: true, pattern: report.findings[1].pattern}; + } + return {found: false, pattern: ""}; + } + +}