diff --git a/changelog.d/routes-filter-format.fixed.md b/changelog.d/routes-filter-format.fixed.md new file mode 100644 index 000000000..aacfe6833 --- /dev/null +++ b/changelog.d/routes-filter-format.fixed.md @@ -0,0 +1 @@ +- `wheels routes --filter=` 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 diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc index d97204262..66edb505f 100644 --- a/cli/lucli/Module.cfc +++ b/cli/lucli/Module.cfc @@ -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() { @@ -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(), @@ -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 { @@ -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") { @@ -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, diff --git a/cli/lucli/tests/_fixtures/commands/ModuleArgvProbe.cfc b/cli/lucli/tests/_fixtures/commands/ModuleArgvProbe.cfc index 5cb771184..99051e2a2 100644 --- a/cli/lucli/tests/_fixtures/commands/ModuleArgvProbe.cfc +++ b/cli/lucli/tests/_fixtures/commands/ModuleArgvProbe.cfc @@ -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); } diff --git a/cli/lucli/tests/specs/commands/McpToolSpecsSpec.cfc b/cli/lucli/tests/specs/commands/McpToolSpecsSpec.cfc index 268df41e5..0f90b9122 100644 --- a/cli/lucli/tests/specs/commands/McpToolSpecsSpec.cfc +++ b/cli/lucli/tests/specs/commands/McpToolSpecsSpec.cfc @@ -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) { diff --git a/cli/lucli/tests/specs/commands/RoutesCommandSpec.cfc b/cli/lucli/tests/specs/commands/RoutesCommandSpec.cfc new file mode 100644 index 000000000..b06207add --- /dev/null +++ b/cli/lucli/tests/specs/commands/RoutesCommandSpec.cfc @@ -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"); + }); + + }); + + } + +} diff --git a/docs/presentations/cfug-2026-09-15/runbook.md b/docs/presentations/cfug-2026-09-15/runbook.md index c822b520f..9ecb05951 100644 --- a/docs/presentations/cfug-2026-09-15/runbook.md +++ b/docs/presentations/cfug-2026-09-15/runbook.md @@ -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`: