Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,18 @@ All historical references to "CFWheels" in this changelog have been preserved fo

## [Unreleased]

### Added

- `wheels upgrade check --strict` escalates advisory findings (the "Recommended Improvements" section) to the same hard-fail path as breaking findings, throwing `Wheels.UpgradeCheckFailed` and exiting non-zero so CI pipelines can gate on opt-in convention changes. Without `--strict`, advisories continue to report-and-pass. Under `--format=json` the emitted document's `success` field is gated on both breaking findings and the strict-advisory case, and the `strict` flag is echoed back so `jq .success` and `$?` always agree. The flag is documented in `wheels upgrade` help output (#2963).
- `services/ArgSpec.toInputSchema()` derives a JSON-Schema-compatible `{type:"object", properties, required, additionalProperties:false}` envelope from a command's declared positionals / flags / options. `positional()` / `flag()` / `option()` now accept an optional `description` argument that flows into each emitted property (FastMCP / Symfony JsonDescriptor pattern). Foundation for per-tool MCP input schemas; wiring into `tools/list` is a follow-up (#2963).

### Performance

- `URLFor()` controller/action route lookup is now memoized in application scope with negative caching, instead of a per-request memo that only cached matches. The previous memo was rebuilt on every request and was never written on a miss, so wildcard-`[controller]` apps — where `$addRoute` strips the `controller` key, guaranteeing no match — re-scanned the entire route table for every `linkTo` / `urlFor` / `redirectTo` call, on every request. The new `application.wheels.urlForCache` survives across requests and caches both hits and misses (empty-string sentinel) for O(1) lookup. Invalidation is plumbed through both `$lockedLoadRoutes` (route reload) and `$addRoute` (any mutation, including test-suite manipulation), so a previously negative-cached `(controller, action)` pair that a newly-added route now matches can never serve a stale miss (#2955)

### Fixed

- `mcpHiddenTools()` now structurally appends every `$`-prefixed PUBLIC function discovered via `getMetaData(this)` to the hidden list, in addition to the explicit literal entries. Defense-in-depth: a future `$publicHelper` added without a denylist update can no longer accidentally leak as a callable MCP tool. The literal `$normalizeTestFilter` / `$resolveAppTestDataSource` entries are retained for clarity and the case where LuCLI consults the list before metadata is fully populated; the structural pass de-duplicates and catches additions (#2963).
- Dispatch now caches resolved route-scoped string middleware as application-scope singletons keyed by component path, so stateful middleware (e.g. an in-memory `RateLimiter` registered on a `.scope(path="/api", middleware=[...])`) accumulates state across requests instead of getting a fresh, empty instance per request. `$copyRouteForRequest` shallow-copies the route's `middleware` array instead of `Duplicate()`-ing it so Adobe CF (which clones CFCs inside arrays) doesn't silently reset the cached instances. The preflight-capability boolean is now computed once at `$init` and stored on the Dispatch instance, replacing the per-OPTIONS-request `IsInstanceOf` scan over the global pipeline. Documents the singleton lifecycle contract: middleware components must be safe to share across concurrent requests, which every built-in middleware already is (#2954)
- `Job.processQueue()`'s private `$processJob` now guards the claim `UPDATE` with `AND status = 'pending'` and verifies the affected-row count via the `queryExecute` `result` option on the same statement, mirroring the matrix-proven `JobWorker.cfc::$claimJob` idiom (a separate verification `SELECT` breaks on BoxLang + PostgreSQL when the connection pool hands out a different connection that cannot see the uncommitted UPDATE). Pre-fix, two concurrent claimers — overlapping `processQueue()` callers, or `processQueue` racing the CLI worker — could both claim the same job and both run `perform()` (duplicate emails/charges, with `attempts` double-incremented). A lost claim now early-returns `{success = false, skipped = true}` before job instantiation, tenant-context setup, and `perform()`; `processQueue()` counts lost claims under a new additive `skipped` result key (#2899)
- `onlyProvides()` per-action format restrictions now take effect. `$acceptableFormats()` was reading the top-level `variables.$class.formats` struct instead of the `.actions` sub-struct that `onlyProvides()` writes to, making every per-action restriction a silent no-op since introduction. **Behavior change by design** — apps that relied on the silent no-op will now see restrictions enforced (#2901)
Expand Down
77 changes: 71 additions & 6 deletions cli/lucli/Module.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,20 @@ component extends="modules.BaseModule" {
* interactive (console), meta (mcp), alias (d), or don't translate to
* single-call MCP semantics (browser). Read by LuCLI >= 0.3.4 per the
* mcpHiddenTools() convention.
*
* Defense-in-depth (#2963 / wave-2 §5.2): every public function whose
* name starts with `$` is appended structurally via `getMetaData(this)`.
* These are the "public for specs" carve-out helpers documented in
* cli/CLAUDE.md — kept public only so TestBox can reach them — and must
* never appear in MCP `tools/list`. Without this structural sweep, a
* future `$publicHelperFour` added without a denylist update would leak
* as a callable tool. The literal `$normalizeTestFilter` /
* `$resolveAppTestDataSource` entries below are retained for clarity
* and the case where LuCLI consults the list before metadata is fully
* populated; the structural pass de-duplicates and catches additions.
*/
public array function mcpHiddenTools() {
return [
var hidden = [
"main", // bare `wheels` no-args dispatch target — not an MCP tool
"mcp", // meta command — prints MCP setup instructions
"d", // alias for destroy
Expand All @@ -183,6 +194,32 @@ component extends="modules.BaseModule" {
"$normalizeTestFilter",
"$resolveAppTestDataSource"
];

// Structural sweep — discover every $-prefixed PUBLIC function on
// this module via getMetaData(this).functions and add anything not
// already listed. Defense-in-depth so a future $-helper added without
// a denylist update can't accidentally leak as an MCP tool.
try {
var meta = getMetaData(this);
if (structKeyExists(meta, "functions") && isArray(meta.functions)) {
for (var fn in meta.functions) {
if (
structKeyExists(fn, "name")
&& structKeyExists(fn, "access")
&& fn.access == "public"
&& left(fn.name, 1) == "$"
&& !arrayContainsNoCase(hidden, fn.name)
) {
arrayAppend(hidden, fn.name);
}
}
}
} catch (any e) {
// Reflection failure: fall through to the literal denylist.
// The hard-coded $-entries above still cover the two known cases.
}

return hidden;
}

// ─────────────────────────────────────────────────
Expand Down Expand Up @@ -2733,11 +2770,17 @@ component extends="modules.BaseModule" {
.positional(name = "subcommand", default = "")
.option(name = "to", default = "")
.option(name = "format", default = "")
.flag(name = "strict", default = false)
.parse(arguments.coll);
return {
isCheck = lCase(parsed.subcommand) == "check",
targetVersion = parsed.to,
format = parsed.format,
// #2963: --strict escalates advisory findings to a hard failure
// (throws Wheels.UpgradeCheckFailed) so CI can gate on opt-in
// recommendations, not just breaking changes. Mirrors Django
// --fail-level WARNING / Mix --warnings-as-errors.
strict = parsed.strict,
sawTo = structKeyExists(arguments.coll, "to"),
sawDryRun = structKeyExists(arguments.coll, "dry-run")
};
Expand Down Expand Up @@ -2768,17 +2811,20 @@ component extends="modules.BaseModule" {

if (!opts.isCheck) {
var nl = chr(10);
var help = "Usage: wheels upgrade check [--to=<version>]" & nl
var help = "Usage: wheels upgrade check [--to=<version>] [--strict] [--format=json]" & nl
& nl
& "Scans your app for breaking changes between Wheels versions." & nl
& "This command is read-only — it does not modify vendor/wheels/." & nl
& nl
& "Options:" & nl
& " --to=<version> Target Wheels version (default: latest stable)" & nl
& " --format=json Emit a machine-readable JSON report" & nl
& " --strict Treat advisory findings (recommended improvements) as failures" & nl
& " Useful for CI — opt-in convention changes will gate the build." & nl
& nl
& "Exit status:" & nl
& " Non-zero when breaking changes are found (advisories never fail the check)." & nl
& " Non-zero when breaking changes are found. With --strict, advisory findings" & nl
& " also fail the check; without --strict, advisories never affect the exit code." & nl
& nl
& "Unsupported flags:" & nl
& " --dry-run is not supported — the command is already read-only," & nl
Expand All @@ -2800,7 +2846,7 @@ component extends="modules.BaseModule" {
return help;
}

return runUpgradeCheck(opts.targetVersion, opts.format);
return runUpgradeCheck(opts.targetVersion, opts.format, opts.strict);
}

// ─────────────────────────────────────────────────
Expand Down Expand Up @@ -4074,7 +4120,7 @@ component extends="modules.BaseModule" {
* the exit code. `format="json"` replaces the human report with a single
* JSON document for pipelines.
*/
private string function runUpgradeCheck(string targetVersion = "", string format = "") {
private string function runUpgradeCheck(string targetVersion = "", string format = "", boolean strict = false) {
var jsonMode = lCase(arguments.format) == "json";
// Detect current version. Prefer wheels.json (post-rename) and fall back
// to box.json so apps with pre-rename vendor/wheels/ committed in their
Expand Down Expand Up @@ -4518,14 +4564,20 @@ component extends="modules.BaseModule" {
var guideUrl = "https://guides.wheels.dev/v4-0-0/upgrading/"
& (targetMajor >= 4 ? "3x-to-4x" : "2x-to-3x") & "/";

// `success` must reflect every condition that produces a non-zero
// exit, otherwise `jq .success` and `$?` disagree when --strict is
// active with advisory-only findings (#2963 review round 1).
var strictAdvisoryFail = arguments.strict && arrayLen(advisories) > 0;

// JSON mode — one machine-readable document, no human report. The
// breaking-findings throw below still fires so pipelines can gate on
// the exit code without parsing stdout.
if (jsonMode) {
out(serializeJSON({
"currentVersion": currentVersion,
"targetVersion": target,
"success": arrayLen(issues) == 0,
"success": arrayLen(issues) == 0 && !strictAdvisoryFail,
"strict": arguments.strict,
"breaking": issues,
"advisories": advisories,
"passed": passed,
Expand Down Expand Up @@ -4587,6 +4639,19 @@ component extends="modules.BaseModule" {
);
}

// #2963: --strict escalates advisory findings to the same hard-fail
// path. Reuses Wheels.UpgradeCheckFailed so CI pipelines that already
// filter on the breaking-case type pick the strict case up too. The
// breaking branch above already returned, so this fires only when
// strict mode is on AND at least one advisory matched but no
// breaking finding did.
if (arguments.strict && arrayLen(advisories)) {
throw(
type = "Wheels.UpgradeCheckFailed",
message = "Upgrade check found #arrayLen(advisories)# advisory finding(s) and --strict is set — see the report above."
);
}

return "";
}

Expand Down
96 changes: 90 additions & 6 deletions cli/lucli/services/ArgSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -42,36 +42,42 @@ component {
required string name,
boolean required = false,
any default = "",
string type = "string"
string type = "string",
string description = ""
) {
arrayAppend(variables.positionals, {
"name" = arguments.name,
"required" = arguments.required,
"default" = arguments.default,
"type" = arguments.type
"type" = arguments.type,
"description" = arguments.description
});
return this;
}

public any function flag(
required string name,
boolean default = false
boolean default = false,
string description = ""
) {
variables.named[arguments.name] = {
"default" = arguments.default,
"type" = "boolean"
"type" = "boolean",
"description" = arguments.description
};
return this;
}

public any function option(
required string name,
any default = "",
string type = "string"
string type = "string",
string description = ""
) {
variables.named[arguments.name] = {
"default" = arguments.default,
"type" = arguments.type
"type" = arguments.type,
"description" = arguments.description
};
return this;
}
Expand Down Expand Up @@ -169,6 +175,84 @@ component {
return result;
}

/**
* Emit a JSON-Schema-compatible input schema describing this spec.
*
* The auto-discovered MCP tools in Module.cfc currently advertise empty
* `properties` so clients can't discover parameters (#2963). Per the
* cross-framework research (FastMCP, MCP TypeScript SDK, Symfony
* JsonDescriptor): derive the schema from the same typed declaration
* the command already uses. One source of truth, no hand-written drift.
*
* Result shape (matches MCP `tools/list[].inputSchema`):
*
* {
* "type": "object",
* "properties": {
* "appName": {"type": "string", "description": "...", "default": ""},
* "sqlite": {"type": "boolean", "description": "...", "default": true},
* "datasource": {"type": "string", "description": "...", "default": ""}
* },
* "required": ["appName"],
* "additionalProperties": false
* }
*
* Type mapping follows CFML/ArgSpec coercion: positional/option strings
* become JSON Schema "string"; numeric-typed options become "number";
* flags become "boolean". `additionalProperties: false` matches the
* mcpHiddenTools surface convention — unknown keys are rejected at the
* MCP client.
*/
public struct function toInputSchema() {
var properties = {};
var required = [];

for (var p in variables.positionals) {
properties[p.name] = $toSchemaProperty(p.type, p["default"], p.description);
if (p.required) {
arrayAppend(required, p.name);
}
}

for (var optName in variables.named) {
var spec = variables.named[optName];
properties[optName] = $toSchemaProperty(spec.type, spec["default"], spec.description);
}

return {
"type" = "object",
"properties" = properties,
"required" = required,
"additionalProperties" = false
};
}

private struct function $toSchemaProperty(
required string type,
required any default,
string description = ""
) {
var prop = {
"type" = $toJsonSchemaType(arguments.type),
"default" = arguments.default
};
if (len(arguments.description)) {
prop["description"] = arguments.description;
}
return prop;
}

private string function $toJsonSchemaType(required string cfmlType) {
switch (arguments.cfmlType) {
case "boolean":
return "boolean";
case "numeric":
return "number";
default:
return "string";
}
}

/**
* Collect the numeric index of every positional (arg<N>) key in the
* collection, sorted ascending. LuCLI numbers positionals by global token
Expand Down
86 changes: 86 additions & 0 deletions cli/lucli/tests/specs/commands/McpHiddenToolsSpec.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* Defense-in-depth coverage for `mcpHiddenTools()` — the function LuCLI reads
* to filter the MCP `tools/list` surface. Auto-discovered MCP tools include
* every public function in Module.cfc; without an exclusion path, a
* `$`-prefixed public helper (kept public only so unit tests can reach it —
* the cli/CLAUDE.md "public for specs" carve-out) leaks into `tools/list`
* verbatim, surfacing as a callable MCP tool.
*
* Issue #2963 / wave-2 §5.2 (Part 2) flagged this: the static denylist works
* for the two known cases (`$normalizeTestFilter`, `$resolveAppTestDataSource`),
* but a future `$publicHelperFour` added without a denylist update would leak.
* The fix structurally discovers every `$`-prefixed public function via
* `getMetaData(this)` so the exclusion is self-maintaining.
*
* Like every other Module.cfc spec, source-level inspection — Module extends
* `modules.BaseModule`, which is only resolvable at LuCLI runtime, not in
* TestBox (see UpgradeCommandSpec / MainCommandSpec).
*/
component extends="wheels.wheelstest.system.BaseSpec" {

function beforeAll() {
variables.moduleSource = fileRead(expandPath("/cli/lucli/Module.cfc"));
}

function run() {

describe("mcpHiddenTools() — structural $-prefix exclusion (##2963)", () => {

it("walks the module's own public functions via getMetaData(this)", () => {
// The fix replaces the hard-coded list with reflection over
// the module's own metadata, so adding a new $-prefixed public
// helper can't accidentally leak as an MCP tool. Source-level:
// the function body must call getMetaData(this) (or this.$ ...
// → metadata-driven discovery), not just return a static array.
var startIdx = reFindNoCase("(?m)^[ \t]*public\s+array\s+function\s+mcpHiddenTools\s*\(", variables.moduleSource);
expect(startIdx).toBeGT(0);
var body = mid(variables.moduleSource, startIdx, 2500);
expect(body).toInclude("getMetaData(this)");
});

it("excludes every $-prefixed public function discovered in the module", () => {
// The discovery loop must filter on the leading `$` so future
// $publicHelper functions are auto-excluded. Match any of the
// idiomatic prefix tests (left, mid, find/findNoCase at position 1).
var startIdx = reFindNoCase("(?m)^[ \t]*public\s+array\s+function\s+mcpHiddenTools\s*\(", variables.moduleSource);
expect(startIdx).toBeGT(0);
var body = mid(variables.moduleSource, startIdx, 2500);
// One of these prefix tests must appear inside the function
// body. Loose match so an equivalent rewrite still passes.
var hasLeftPrefix = reFindNoCase("left\s*\(\s*[a-zA-Z_]+\.name\s*,\s*1\s*\)\s*==\s*""\$""", body) > 0;
var hasFindPrefix = reFindNoCase("find\s*\(\s*""\$""\s*,\s*[a-zA-Z_]+\.name\s*\)\s*==\s*1", body) > 0;
var hasReFindPrefix = reFindNoCase("reFind(NoCase)?\s*\(\s*""\^\\\$""\s*,\s*[a-zA-Z_]+\.name\s*\)", body) > 0;
expect(hasLeftPrefix || hasFindPrefix || hasReFindPrefix).toBeTrue();
});

it("keeps non-$-prefixed CLI-only commands in the hidden list", () => {
// The structural prefix filter doesn't cover commands like
// `start`, `stop`, `new`, `console`, `browser` — these are
// public, non-$-prefixed, and CLI-only. They must remain in
// the explicit denylist that the function returns. Source:
// the literal strings still appear in the function body.
var startIdx = reFindNoCase("(?m)^[ \t]*public\s+array\s+function\s+mcpHiddenTools\s*\(", variables.moduleSource);
expect(startIdx).toBeGT(0);
var body = mid(variables.moduleSource, startIdx, 2500);
expect(body).toInclude("""start""");
expect(body).toInclude("""stop""");
expect(body).toInclude("""new""");
expect(body).toInclude("""console""");
expect(body).toInclude("""browser""");
expect(body).toInclude("""mcp""");
});

it("returns an array (the LuCLI mcpHiddenTools() contract)", () => {
// LuCLI calls mcpHiddenTools() and expects an array of
// string names. Source-level: the return type is `array`
// and the body returns one. Smoke test against the
// declaration only — full integration is covered by LuCLI's
// own tools/list invocations end-to-end.
expect(reFindNoCase("(?m)^[ \t]*public\s+array\s+function\s+mcpHiddenTools\s*\(", variables.moduleSource)).toBeGT(0);
});

});

}

}
Loading
Loading