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
1 change: 1 addition & 0 deletions changelog.d/routes-filter-format.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `wheels routes --filter=<text>` and `--format=json` now work. Both were advertised in the command's help for as long as it existed and neither was ever read — the command printed every route unconditionally. `--filter` matches name, pattern or `controller#action` (case-insensitive, literal); `--format=json` emits the filtered array with lower-case keys so `jq .pattern` works
68 changes: 64 additions & 4 deletions cli/lucli/Module.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ component extends="modules.BaseModule" {
* the MCP advertisement cannot drift.
*
* Commands still on hand-rolled token parsing (generate, migrate, db,
* deploy, routes, info, reload, validate, create — tracked by #2861)
* deploy, info, reload, validate, create — tracked by #2861)
* gain entries here as they migrate to ArgSpec.
*/
public struct function mcpToolSpecs() {
Expand All @@ -263,6 +263,7 @@ component extends="modules.BaseModule" {
"generate" = generateArgSpec().toInputSchema(),
"migrate" = migrateArgSpec().toInputSchema(),
"notes" = notesArgSpec().toInputSchema(),
"routes" = routesArgSpec().toInputSchema(),
"seed" = seedArgSpec().toInputSchema(),
"stats" = verboseFlagSpec().toInputSchema(),
"test" = testArgSpec().toInputSchema(),
Expand Down Expand Up @@ -1938,7 +1939,20 @@ component extends="modules.BaseModule" {
/**
* hint: List all configured routes with method, path, and controller action
*/
private any function routesArgSpec() {
return new services.ArgSpec()
.option(name = "filter", default = "", description = "Show only routes whose name, pattern or controller##action contains this text (case-insensitive)")
.option(name = "format", default = "text", description = "Output format: text (aligned table) or json");
}

public string function routes() {
// Both flags were advertised in the wrapper's help for as long as the
// command has existed, and neither was ever read — the command fetched
// every route and printed the table unconditionally. Found while
// rehearsing `wheels routes --filter=posts` as a before/after for the
// scaffold beat: it returned all 57 routes, which on stage reads as a
// bug in front of the audience.
var opts = routesArgSpec().parse(structuredArgs(arguments));
var serverPort = $requireRunningServer();

try {
Expand All @@ -1963,12 +1977,36 @@ component extends="modules.BaseModule" {
throw(type = "Wheels.RoutesFailed", message = "Failed to fetch routes: #result.message ?: 'unknown error'#");
}

if (!structKeyExists(result, "routes") || !arrayLen(result.routes)) {
out("No routes configured.", "yellow");
var routes = structKeyExists(result, "routes") ? result.routes : [];
if (len(opts.filter)) {
routes = $filterRoutes(routes, opts.filter);
}

if (lCase(opts.format) == "json") {
// Machine-readable: the filtered array and nothing else on stdout.
// Rebuild each route with quoted lower-case keys — a CFML struct
// serializes its keys UPPER-CASE, and `jq .pattern` on {"PATTERN":..}
// silently yields null. Field set matches the text table.
var shaped = [];
for (var route in routes) {
arrayAppend(shaped, {
"methods": route.methods ?: "",
"pattern": route.pattern ?: "",
"controller": route.controller ?: "",
"action": route.action ?: "",
"name": route.name ?: ""
});
}
out(serializeJSON(shaped), "");
return "";
}

if (!arrayLen(routes)) {
out(len(opts.filter) ? "No routes match '#opts.filter#'." : "No routes configured.", "yellow");
return "";
}

$printRoutesTable(result.routes);
$printRoutesTable(routes);
} catch (any e) {
// Inner Wheels.RoutesFailed paths already printed a diagnostic; only HTTP/unexpected errors need one here.
if (e.type != "Wheels.RoutesFailed") {
Expand All @@ -1979,6 +2017,28 @@ component extends="modules.BaseModule" {
return "";
}

/**
* Keep routes whose name, pattern, or controller##action contains the
* filter text — the three fields the help text names. Case-insensitive
* substring, not a regex: a presenter typing `--filter=posts` should not
* have to think about escaping, and `[key]` in a pattern must be literal.
*/
private array function $filterRoutes(required array routes, required string filter) {
var needle = lCase(arguments.filter);
var kept = [];
for (var route in arguments.routes) {
var haystack = lCase(
(route.name ?: "") & " "
& (route.pattern ?: "") & " "
& (route.controller ?: "") & "##" & (route.action ?: "")
);
if (find(needle, haystack)) {
arrayAppend(kept, route);
}
}
return kept;
}

/**
* Print the aligned route table. Patterns are normalised so the leading
* "/" is shown exactly once (the framework stores them with it already,
Expand Down
8 changes: 8 additions & 0 deletions cli/lucli/tests/_fixtures/commands/ModuleArgvProbe.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ component extends="cli.lucli.Module" {
return parseSeedArgs(arguments.coll);
}

public array function $filterRoutesProbe(required array routes, required string filter) {
return $filterRoutes(arguments.routes, arguments.filter);
}

public struct function $parseRoutesArgs(required struct coll) {
return routesArgSpec().parse(arguments.coll);
}

public struct function $parseNotesArgs(required struct coll) {
return parseNotesArgs(arguments.coll);
}
Expand Down
9 changes: 9 additions & 0 deletions cli/lucli/tests/specs/commands/McpToolSpecsSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ component extends="wheels.wheelstest.system.BaseSpec" {
expect(schema.properties.strict.type).toBe("boolean");
});

it("advertises routes' filter and format so an assistant can ask for a subset", () => {
// `routes` migrated from hand-rolled parsing to ArgSpec: the
// wrapper's help had promised --filter and --format for as long
// as the command existed, and neither was ever read.
var schema = probe.mcpToolSpecs().routes;
expect(schema.properties).toHaveKey("filter");
expect(schema.properties).toHaveKey("format");
});

it("describes every property so MCP clients see usable parameter docs", () => {
var specs = probe.mcpToolSpecs();
for (var toolName in specs) {
Expand Down
96 changes: 96 additions & 0 deletions cli/lucli/tests/specs/commands/RoutesCommandSpec.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* `wheels routes --filter=` and `--format=`.
*
* Both flags were advertised in the wrapper's help for as long as the
* command existed, and neither was ever read — routes() fetched every
* route and printed the table unconditionally. Found rehearsing
* `wheels routes --filter=posts` as a before/after for the scaffold beat:
* it returned all 57 routes, which on stage reads as a bug.
*/
component extends="wheels.wheelstest.system.BaseSpec" {

function beforeAll() {
variables.probe = new cli.lucli.tests._fixtures.commands.ModuleArgvProbe(
cwd = expandPath("/")
);
// The shape /wheels/cli?command=routes returns, trimmed to what matters.
variables.table = [
{methods: "get", pattern: "/wheels/info", controller: "wheels.public", action: "info", name: "wheelsInfo"},
{methods: "get", pattern: "/posts", controller: "posts", action: "index", name: "posts"},
{methods: "get", pattern: "/posts/[key]", controller: "posts", action: "show", name: "post"},
{methods: "delete", pattern: "/posts/[key]", controller: "posts", action: "delete", name: "post"},
{methods: "get", pattern: "/comments/[key]", controller: "comments", action: "show", name: "comment"},
{methods: "get", pattern: "/", controller: "main", action: "index", name: "root"}
];
}

function run() {

describe("$filterRoutes()", () => {

it("keeps only routes whose pattern contains the text", () => {
var kept = probe.$filterRoutesProbe(table, "posts");
expect(arrayLen(kept)).toBe(3);
for (var r in kept) {
expect(r.controller).toBe("posts");
}
});

it("is case-insensitive — a presenter should not have to match case", () => {
expect(arrayLen(probe.$filterRoutesProbe(table, "POSTS"))).toBe(3);
expect(arrayLen(probe.$filterRoutesProbe(table, "Posts"))).toBe(3);
});

it("matches on the route NAME, not just the pattern", () => {
// `root` appears only in the name field.
var kept = probe.$filterRoutesProbe(table, "root");
expect(arrayLen(kept)).toBe(1);
expect(kept[1].pattern).toBe("/");
});

it("matches on controller##action", () => {
var kept = probe.$filterRoutesProbe(table, "comments##show");
expect(arrayLen(kept)).toBe(1);
expect(kept[1].controller).toBe("comments");
});

it("treats brackets literally — [key] is a substring, not a regex class", () => {
// A regex would read [key] as "one of k,e,y" and match almost everything.
var kept = probe.$filterRoutesProbe(table, "[key]");
expect(arrayLen(kept)).toBe(3);
for (var r in kept) {
expect(r.pattern).toInclude("[key]");
}
});

it("returns an empty array when nothing matches", () => {
expect(arrayLen(probe.$filterRoutesProbe(table, "nomatch"))).toBe(0);
});

it("does not mutate the input", () => {
var before = arrayLen(table);
probe.$filterRoutesProbe(table, "posts");
expect(arrayLen(table)).toBe(before);
});

});

describe("routes ArgSpec", () => {

it("defaults filter to empty and format to text", () => {
var opts = probe.$parseRoutesArgs({});
expect(opts.filter).toBe("");
expect(opts.format).toBe("text");
});

it("reads --filter and --format from the structured handoff", () => {
var opts = probe.$parseRoutesArgs({filter: "posts", format: "json"});
expect(opts.filter).toBe("posts");
expect(opts.format).toBe("json");
});

});

}

}
36 changes: 33 additions & 3 deletions docs/presentations/cfug-2026-09-15/runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,45 @@ wheels generate scaffold Post 'title:string{50}' body:text publishedAt:datetime

```bash
wheels routes
```

**See:** **41 route(s)** — and every one of them is under `/wheels/…`,
plus `/up`, the two wildcards, and `/`. Not one is *yours*.

**Say:** "Forty-one routes and I haven't written any. That's the framework's
own tooling — docs, tests, migrator, the console endpoint — mounted under
`/wheels`. Remember the number."

```bash
wheels generate scaffold Post 'title:string{50}' body:text publishedAt:datetime
wheels migrate latest
wheels seed --generate
wheels reload
wheels routes
wheels routes --filter=posts
```

**See:** `Created table posts` · `Seeded: 10 created, 0 skipped` · the
`posts` resource routes.
**See:** `Created table posts` · `Seeded: 10 created, 0 skipped` · then a
table of exactly **16 route(s)**, all `posts#…` — index, show, new, edit,
create, update, delete, each in plain and `.[format]` form, with `PATCH` and
`PUT` both mapped to `update`.

**Say:** "Forty-one became fifty-seven. Sixteen routes from one line."

Open `config/routes.cfm` and point at that line:

```cfm
.resources("posts")
```

**Why it matters.** The scaffold didn't just write files — it *registered*
the resource. `.resources()` expands to the full REST surface, so the routes
you see are the routes the framework will actually dispatch. `--filter`
keeps the projector honest: the audience sees the sixteen that changed, not
fifty-seven lines to hunt through.

> If you want the raw total for the "fifty-seven" line, run plain
> `wheels routes` and read the last line. The filter view alone is enough
> for the point.

Open `app/models/Post.cfc`:

Expand Down
Loading