Skip to content

Commit cf00d63

Browse files
bpamiriclaude
andauthored
fix(cli): upgrade-check exit code, breaker-scan coverage, ArgSpec positional gaps (#2941)
U1: wheels upgrade check now throws Wheels.UpgradeCheckFailed after the report flushes when breaking findings exist (mirrors validate()'s Wheels.ValidationFailed), so it exits non-zero and can gate CI. Adds --format=json for a machine-readable report; advisories never affect the exit code. U4: breaker scan covers the canonical eleven — the WireBox check now matches wirebox.system.ioc and scans the root Application.cfc + config/ in addition to app/; new breaking grep for removed renderPage()/ renderPageToString(); new advisories for the HSTS default flip (SecurityHeaders, #2081) and the CSRF SameSite cookie attribute (protectsFromForgery, #2035). UpgradeCheckCoverageSpec pins the full set plus the exit-code throw and --format=json plumbing. U5: wheels.Testbox shim now documented with a 5.0 removal target (CFC docblock + 3x-to-4x guide), mapperModernSpec migrated off it to wheels.WheelsTest, and the upgrade-check test-base-class grep matches wheels.Testbox and single-quoted extends forms. U7: renamed the scanTargets loop var that shadowed the target-version local; fixed the dead 2.x->3.x app/plugins check to the real root plugins/ path (skipped on 2.x->4.x jumps where the 4.x entry covers it); breaking-finding output now links the version-appropriate upgrade guide and the wheels-legacy-adapter soft landing; new in-repo regression spec for wheels-legacy-adapter registry resolution. CLI-D5: structuredArgs() now consumes __arguments once (cleared on every call), so a delegation stash (create/generate app -> new) can never replay stale argv into a later zero-arg tool call under the persistent stdio MCP server. CLI-D1: ArgSpec.parse()/toArgv() bind positionals by collecting and numerically sorting arg<N> keys (parseTestArgs' proven pattern) instead of fixed-index probing / stop-at-gap loops. LuCLI numbers positionals by global token index, so a flag between positionals leaves a gap — 'wheels new --port=3000 blog' previously ignored the app name and threw, and 'wheels g scaffold Post --force title:string body:text' silently dropped every column after the flag. Verified locally (Lucee 7 + SQLite, worktree docker recipe): core cli specs 78/78, mapperModernSpec 27/27, CLI suite 774 pass with only the pre-existing environment-dependent SSH/live-server failures. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ee848b6 commit cf00d63

11 files changed

Lines changed: 479 additions & 82 deletions

File tree

‎cli/lucli/Module.cfc‎

Lines changed: 177 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,20 @@ component extends="modules.BaseModule" {
9898
* `create` → `new`, and unit tests). That array is reconstructed into the
9999
* same structured shape LuCLI would have produced, so a command behaves
100100
* identically whether LuCLI dispatched it or another command delegated to it.
101+
*
102+
* `__arguments` is consume-once: it is cleared on every call so a stale
103+
* stash can never replay. One-shot CLI runs hid the leak, but the stdio
104+
* MCP server (`wheels mcp wheels`) is a persistent process — after any
105+
* delegating call (create/generate app → new) a later zero-arg tool call
106+
* (e.g. wheels_test()) would otherwise re-parse the stale argv and turn
107+
* into a completely different invocation.
101108
*/
102109
private struct function structuredArgs(struct callerArgs = {}) {
110+
var raw = __arguments ?: [];
111+
__arguments = [];
103112
if (!structIsEmpty(arguments.callerArgs)) {
104113
return arguments.callerArgs;
105114
}
106-
var raw = __arguments ?: [];
107115
return argvToCollection(isArray(raw) ? raw : []);
108116
}
109117

@@ -2724,10 +2732,12 @@ component extends="modules.BaseModule" {
27242732
var parsed = new services.ArgSpec()
27252733
.positional(name = "subcommand", default = "")
27262734
.option(name = "to", default = "")
2735+
.option(name = "format", default = "")
27272736
.parse(arguments.coll);
27282737
return {
27292738
isCheck = lCase(parsed.subcommand) == "check",
27302739
targetVersion = parsed.to,
2740+
format = parsed.format,
27312741
sawTo = structKeyExists(arguments.coll, "to"),
27322742
sawDryRun = structKeyExists(arguments.coll, "dry-run")
27332743
};
@@ -2744,9 +2754,14 @@ component extends="modules.BaseModule" {
27442754
* Despite occasional appearances in older help output, `--dry-run` is not
27452755
* supported — the command is already read-only by design.
27462756
*
2757+
* Breaking findings throw Wheels.UpgradeCheckFailed after the report is
2758+
* printed, so the command exits non-zero and can gate CI. Advisory
2759+
* (opt-in recommendation) findings never affect the exit code.
2760+
*
27472761
* Examples:
2748-
* wheels upgrade check - scan against the latest stable release
2749-
* wheels upgrade check --to=4.0.0 - scan against a specific target version
2762+
* wheels upgrade check - scan against the latest stable release
2763+
* wheels upgrade check --to=4.0.0 - scan against a specific target version
2764+
* wheels upgrade check --format=json - machine-readable report (CI pipelines)
27502765
*/
27512766
public string function upgrade() {
27522767
var opts = parseUpgradeArgs(structuredArgs(arguments));
@@ -2760,6 +2775,10 @@ component extends="modules.BaseModule" {
27602775
& nl
27612776
& "Options:" & nl
27622777
& " --to=<version> Target Wheels version (default: latest stable)" & nl
2778+
& " --format=json Emit a machine-readable JSON report" & nl
2779+
& nl
2780+
& "Exit status:" & nl
2781+
& " Non-zero when breaking changes are found (advisories never fail the check)." & nl
27632782
& nl
27642783
& "Unsupported flags:" & nl
27652784
& " --dry-run is not supported — the command is already read-only," & nl
@@ -2781,7 +2800,7 @@ component extends="modules.BaseModule" {
27812800
return help;
27822801
}
27832802

2784-
return runUpgradeCheck(opts.targetVersion);
2803+
return runUpgradeCheck(opts.targetVersion, opts.format);
27852804
}
27862805

27872806
// ─────────────────────────────────────────────────
@@ -4039,8 +4058,15 @@ component extends="modules.BaseModule" {
40394058

40404059
/**
40414060
* Scan app for breaking changes between current and target version.
4061+
*
4062+
* Breaking findings throw Wheels.UpgradeCheckFailed after the report is
4063+
* flushed (same pattern as validate()'s Wheels.ValidationFailed), so the
4064+
* command exits non-zero and can gate CI. Advisory findings never affect
4065+
* the exit code. `format="json"` replaces the human report with a single
4066+
* JSON document for pipelines.
40424067
*/
4043-
private string function runUpgradeCheck(string targetVersion = "") {
4068+
private string function runUpgradeCheck(string targetVersion = "", string format = "") {
4069+
var jsonMode = lCase(arguments.format) == "json";
40444070
// Detect current version. Prefer wheels.json (post-rename) and fall back
40454071
// to box.json so apps with pre-rename vendor/wheels/ committed in their
40464072
// repo still work. The fallback can be removed two releases after the
@@ -4066,21 +4092,24 @@ component extends="modules.BaseModule" {
40664092
var releaseData = deserializeJSON(response);
40674093
target = replace(releaseData.tag_name, "v", "");
40684094
} catch (any e) {
4069-
out("Could not fetch latest version. Use --to=<version> to specify.", "yellow");
4095+
var fetchMsg = "Could not fetch latest version. Use --to=<version> to specify.";
4096+
out(jsonMode ? serializeJSON({"error": fetchMsg}) : fetchMsg, "yellow");
40704097
return "";
40714098
}
40724099
}
40734100

4074-
out("Current version: #currentVersion#", "bold");
4075-
out("Target version: #target#", "bold");
4076-
out("");
4101+
if (!jsonMode) {
4102+
out("Current version: #currentVersion#", "bold");
4103+
out("Target version: #target#", "bold");
4104+
out("");
4105+
}
40774106

40784107
// Compare major versions
40794108
var currentMajor = val(listFirst(currentVersion, "."));
40804109
var targetMajor = val(listFirst(target, "."));
40814110
var sameMajor = (currentMajor == targetMajor);
40824111

4083-
if (sameMajor) {
4112+
if (sameMajor && !jsonMode) {
40844113
out("Same major version — no known breaking changes.", "green");
40854114
out("Scanning for opt-in recommendations...", "green");
40864115
out("");
@@ -4094,16 +4123,23 @@ component extends="modules.BaseModule" {
40944123

40954124
// 2.x -> 3.x
40964125
if (currentMajor <= 2 && targetMajor >= 3) {
4126+
// 2.x plugins lived at the webroot's /plugins (the previous
4127+
// `app/plugins` path never existed in any Wheels layout, so the
4128+
// check was dead). The 3.x -> 4.x block adds an identical root
4129+
// /plugins check, so skip this one on a 2.x -> 4.x jump to avoid
4130+
// reporting the same directory twice.
4131+
if (targetMajor < 4) {
4132+
arrayAppend(checks, {
4133+
description: "Legacy plugin directory",
4134+
pattern: "",
4135+
checkType: "directory",
4136+
path: "plugins",
4137+
fix: "Migrate to packages/ + vendor/ activation model"
4138+
});
4139+
}
40974140
arrayAppend(checks, {
4098-
description: "Legacy plugin directory",
4099-
pattern: "",
4100-
checkType: "directory",
4101-
path: "app/plugins",
4102-
fix: "Migrate to packages/ + vendor/ activation model"
4103-
});
4104-
arrayAppend(checks, {
4105-
description: "Old test base class (wheels.Test)",
4106-
pattern: 'extends\s*=\s*"wheels\.Test"',
4141+
description: "Old test base class (wheels.Test / wheels.Testbox)",
4142+
pattern: 'extends\s*=\s*["'']wheels\.Test(box)?["'']',
41074143
checkType: "grep",
41084144
scanDir: "tests",
41094145
extensions: "cfc",
@@ -4120,21 +4156,68 @@ component extends="modules.BaseModule" {
41204156
path: "plugins",
41214157
fix: "Migrate to packages/ + vendor/ system"
41224158
});
4159+
// Matches both quote styles and the silent wheels.Testbox shim
4160+
// (deprecated alias of wheels.WheelsTest, removal target 5.0) —
4161+
// the previous double-quote-only wheels.Test pattern missed both.
41234162
arrayAppend(checks, {
4124-
description: "Old test base class (wheels.Test)",
4125-
pattern: 'extends\s*=\s*"wheels\.Test"',
4163+
description: "Old test base class (wheels.Test / wheels.Testbox)",
4164+
pattern: 'extends\s*=\s*["'']wheels\.Test(box)?["'']',
41264165
checkType: "grep",
41274166
scanDir: "tests",
41284167
extensions: "cfc",
41294168
fix: 'Change to extends="wheels.WheelsTest"'
41304169
});
4170+
// application.wirebox → application.wheelsdi (guide item 10). The
4171+
// hardest real-world case is a root Application.cfc bootstrap that
4172+
// calls `new wirebox.system.ioc.Injector(...)` — the WireBox
4173+
// package no longer ships in vendor/wheels/ — so scan the root
4174+
// Application.cfc and config/ in addition to app/.
4175+
arrayAppend(checks, {
4176+
description: "Direct WireBox references (application.wirebox / wirebox.system.ioc)",
4177+
pattern: "application\.wirebox|wirebox\.system\.ioc",
4178+
checkType: "grep",
4179+
scanDir: "app",
4180+
extensions: "cfc,cfm",
4181+
scanTargets: [
4182+
{path: "Application.cfc"},
4183+
{path: "config", extensions: "cfm,cfc", recurse: true}
4184+
],
4185+
fix: "Use service() / application.wheelsdi instead of application.wirebox; replace `new wirebox.system.ioc.Injector(...)` bootstraps with `new wheels.Injector()`. The legacy adapter does NOT shim this item."
4186+
});
4187+
// renderPage()/renderPageToString() removed in 4.0 — shimmed by
4188+
// the optional wheels-legacy-adapter package, but unshimmed apps
4189+
// throw at first render.
41314190
arrayAppend(checks, {
4132-
description: "Direct WireBox references",
4133-
pattern: "application\.wirebox",
4191+
description: "Removed renderPage()/renderPageToString() helpers",
4192+
pattern: "renderPage(ToString)?\s*\(",
41344193
checkType: "grep",
41354194
scanDir: "app",
41364195
extensions: "cfc,cfm",
4137-
fix: "Use service() or inject() from the DI container instead"
4196+
fix: 'Replace renderPage() with renderView() and renderPageToString() with renderView(returnAs="string"), or install the soft-landing shim: wheels packages add wheels-legacy-adapter'
4197+
});
4198+
// HSTS defaults on in production (guide item 2, ##2081). Advisory:
4199+
// fires on SecurityHeaders usage so proxied/LB setups know the
4200+
// header now emits by default.
4201+
arrayAppend(checks, {
4202+
description: "SecurityHeaders middleware — HSTS defaults on in production in 4.0 (advisory)",
4203+
severity: "advisory",
4204+
pattern: "new\s+wheels\.middleware\.SecurityHeaders",
4205+
checkType: "grep",
4206+
scanDir: "config",
4207+
extensions: "cfm,cfc",
4208+
fix: "4.0 emits Strict-Transport-Security (max-age=31536000; includeSubDomains) by default in production. Pass hsts=false to the middleware if your load balancer already sets it."
4209+
});
4210+
// CSRF cookie now sets SameSite (guide item 6, ##2035). Advisory:
4211+
// fires on CSRF protection usage — same-site flows are unaffected,
4212+
// but cross-site POSTs from third-party frames will break.
4213+
arrayAppend(checks, {
4214+
description: "CSRF cookie sets SameSite in 4.0 (advisory: review cross-site POST flows)",
4215+
severity: "advisory",
4216+
pattern: "protectsFromForgery",
4217+
checkType: "grep",
4218+
scanDir: "app",
4219+
extensions: "cfc",
4220+
fix: "The CSRF cookie now sets the SameSite attribute. Cross-site POSTs from third-party frames that relied on the missing attribute will break; same-site app flows are unaffected."
41384221
});
41394222
// CORS default flip — wildcard "*" → deny-all (#2039). A bare
41404223
// `new wheels.middleware.Cors()` accepts no requests in 4.0.
@@ -4344,18 +4427,21 @@ component extends="modules.BaseModule" {
43444427
}
43454428

43464429
if (structKeyExists(check, "scanTargets") && isArray(check.scanTargets)) {
4347-
for (var target in check.scanTargets) {
4348-
var targetPath = variables.projectRoot & "/" & target.path;
4430+
// `scanTarget`, not `target` — the function-level `target`
4431+
// above holds the target VERSION string and a same-named
4432+
// loop var would shadow (then clobber) it.
4433+
for (var scanTarget in check.scanTargets) {
4434+
var targetPath = variables.projectRoot & "/" & scanTarget.path;
43494435
if (fileExists(targetPath)) {
43504436
arrayAppend(filesToScan, targetPath);
43514437
} else if (directoryExists(targetPath)) {
4352-
var recurse = structKeyExists(target, "recurse") ? target.recurse : true;
4438+
var recurse = structKeyExists(scanTarget, "recurse") ? scanTarget.recurse : true;
43534439
// Avoid Elvis `?:` on `check.extensions` — Adobe CF
43544440
// throws when the key is absent. The `wheels snippets`
43554441
// check has no top-level `extensions`, so this branch
43564442
// is reached on every Adobe CF run when a target is a
43574443
// directory without its own `extensions` key.
4358-
var exts = structKeyExists(target, "extensions") ? target.extensions
4444+
var exts = structKeyExists(scanTarget, "extensions") ? scanTarget.extensions
43594445
: (structKeyExists(check, "extensions") ? check.extensions : "");
43604446
for (var ext in listToArray(exts)) {
43614447
var dirFiles2 = directoryList(targetPath, recurse, "path", "*." & ext);
@@ -4418,44 +4504,79 @@ component extends="modules.BaseModule" {
44184504
}
44194505
}
44204506

4421-
// Output — three sections in priority order: Breaking → Recommended → All Clear
4422-
if (arrayLen(issues)) {
4423-
out("Breaking Changes (#arrayLen(issues)# found):", "yellow");
4424-
for (var issue in issues) {
4425-
out(" ! #issue.description#", "yellow");
4426-
for (var match in issue.matches) {
4427-
out(" #match#");
4507+
// The version-appropriate guide + the soft-landing adapter, surfaced
4508+
// whenever breaking findings are reported (and always in JSON output).
4509+
var guideUrl = "https://guides.wheels.dev/v4-0-0/upgrading/"
4510+
& (targetMajor >= 4 ? "3x-to-4x" : "2x-to-3x") & "/";
4511+
4512+
// JSON mode — one machine-readable document, no human report. The
4513+
// breaking-findings throw below still fires so pipelines can gate on
4514+
// the exit code without parsing stdout.
4515+
if (jsonMode) {
4516+
out(serializeJSON({
4517+
"currentVersion": currentVersion,
4518+
"targetVersion": target,
4519+
"success": arrayLen(issues) == 0,
4520+
"breaking": issues,
4521+
"advisories": advisories,
4522+
"passed": passed,
4523+
"guide": guideUrl
4524+
}));
4525+
} else {
4526+
// Output — three sections in priority order: Breaking → Recommended → All Clear
4527+
if (arrayLen(issues)) {
4528+
out("Breaking Changes (#arrayLen(issues)# found):", "yellow");
4529+
for (var issue in issues) {
4530+
out(" ! #issue.description#", "yellow");
4531+
for (var match in issue.matches) {
4532+
out(" #match#");
4533+
}
4534+
out(" -> #issue.fix#", "cyan");
4535+
out("");
4536+
}
4537+
out("Upgrade guide: #guideUrl#", "cyan");
4538+
if (targetMajor >= 4) {
4539+
out("Soft landing: wheels packages add wheels-legacy-adapter (shims renderPage()/renderPageToString() while you migrate)", "cyan");
44284540
}
4429-
out(" -> #issue.fix#", "cyan");
44304541
out("");
44314542
}
4432-
}
44334543

4434-
if (arrayLen(advisories)) {
4435-
out("Recommended Improvements (#arrayLen(advisories)# found):", "cyan");
4436-
for (var advisory in advisories) {
4437-
out(" ~ #advisory.description#", "cyan");
4438-
for (var match in advisory.matches) {
4439-
out(" #match#");
4544+
if (arrayLen(advisories)) {
4545+
out("Recommended Improvements (#arrayLen(advisories)# found):", "cyan");
4546+
for (var advisory in advisories) {
4547+
out(" ~ #advisory.description#", "cyan");
4548+
for (var match in advisory.matches) {
4549+
out(" #match#");
4550+
}
4551+
// Advisory fix lines are intentionally uncolored so the
4552+
// section header and description carry the cyan accent and
4553+
// opt-in items read lighter than breaking-change fixes
4554+
// (which use cyan on the fix line for stronger emphasis).
4555+
out(" -> #advisory.fix#");
4556+
out("");
44404557
}
4441-
// Advisory fix lines are intentionally uncolored so the
4442-
// section header and description carry the cyan accent and
4443-
// opt-in items read lighter than breaking-change fixes
4444-
// (which use cyan on the fix line for stronger emphasis).
4445-
out(" -> #advisory.fix#");
4446-
out("");
44474558
}
4448-
}
44494559

4450-
if (arrayLen(passed)) {
4451-
out("All Clear (#arrayLen(passed)# checks):", "green");
4452-
for (var p in passed) {
4453-
out(" + #p#", "green");
4560+
if (arrayLen(passed)) {
4561+
out("All Clear (#arrayLen(passed)# checks):", "green");
4562+
for (var p in passed) {
4563+
out(" + #p#", "green");
4564+
}
44544565
}
4566+
4567+
out("");
4568+
out("Upgrade with: brew upgrade wheels");
44554569
}
44564570

4457-
out("");
4458-
out("Upgrade with: brew upgrade wheels");
4571+
// Throw after the full report flushes — breaking findings exit
4572+
// non-zero (CI gate), advisories and all-clear exit 0. Mirrors
4573+
// validate()'s Wheels.ValidationFailed convention.
4574+
if (arrayLen(issues)) {
4575+
throw(
4576+
type = "Wheels.UpgradeCheckFailed",
4577+
message = "Upgrade check found #arrayLen(issues)# breaking change(s) — see the report above."
4578+
);
4579+
}
44594580

44604581
return "";
44614582
}

0 commit comments

Comments
 (0)