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/wheels-coverage-command.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added a `wheels coverage` command that instruments `app/` with function-level coverage counters, runs the app test suite against the running server, and reports a CRAP ranking (cyclomatic complexity × test coverage) of your most change-risky files. The instrumentation is reverted automatically afterward. Pairs with the debug bar's Complexity panel: complexity is static and always visible; `wheels coverage` adds the test-coverage half of the CRAP score on demand.
52 changes: 52 additions & 0 deletions cli/lucli/Module.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ component extends="modules.BaseModule" {
"stop", // dev server lifecycle (stateful)
"browser", // multi-step browser testing flow
"jobs", // `jobs work` is a long-lived poll loop — no single-call MCP semantics (like start/stop)
"coverage", // instruments app/ on disk then runs the suite — stateful, not single-call MCP semantics
"mcpToolSpecs", // per-tool inputSchema registry read by LuCLI — not itself a tool
// $-prefixed internal helpers. Public ONLY so TestCommandSpec can
// unit-test them directly (the cli/CLAUDE.md "public for specs"
Expand Down Expand Up @@ -819,6 +820,57 @@ component extends="modules.BaseModule" {
);
}

/**
* Coverage: instrument app/ with function-level coverage counters, run the
* app test suite against the running server, and report a CRAP ranking
* (Change Risk Anti-Patterns: complexity^2 x (1 - coverage)^3 + complexity).
* The instrumentation is reverted afterward (originals restored exactly).
*/
public string function coverage() {
var opts = parseCoverageArgs(structuredArgs(arguments));
var serverPort = $requireRunningServer(
hints = [
"Coverage requires a running server bound to this project.",
"Start it with: wheels start"
],
requireProjectConfig = true
);
var appRoot = variables.projectRoot;
var svc = new services.coverage.CoverageService();
var instrumented = 0;
try {
instrumented = svc.$instrument(appRoot & "/app");
$purgeServerCfclasses();
var suite = svc.$runSuite(serverPort, opts.useTestDb);
var coverage = svc.$collect();
var rows = svc.$analyze(appRoot & "/app", coverage);
return svc.$report(rows, opts.top, instrumented, suite.status);
} catch (any e) {
throw(
type = "Wheels.CoverageFailed",
message = e.message,
detail = e.detail ?: ""
);
} finally {
svc.$revert(appRoot & "/app");
}
}

/**
* Parse args for `wheels coverage`: --top N (report length) and
* --no-test-db (test-db=false).
*/
private struct function parseCoverageArgs(required struct coll) {
var parsed = new services.ArgSpec()
.option(name = "top", default = "15")
.flag(name = "test-db", default = true)
.parse(arguments.coll);
return {
top = Val(parsed.top),
useTestDb = parsed["test-db"]
};
}

/**
* Resolve the effective verbose flag for `wheels test`. The LuCLI picocli
* root defines `-v`/`--verbose` as GLOBAL options and consumes them
Expand Down
275 changes: 275 additions & 0 deletions cli/lucli/services/coverage/CoverageService.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
/**
* Coverage service for `wheels coverage` — instruments app/ with function-level
* coverage counters, runs the app test suite over HTTP, and combines the
* collected coverage with per-file cyclomatic complexity into a CRAP ranking
* (Change Risk Anti-Patterns: complexity^2 * (1 - coverage)^3 + complexity).
*
* Counters write to `server.__wheels_cov` (process-wide); the app test runner
* (vendor/wheels/tests/app-runner.cfm) dumps that struct to
* /tmp/wheels-app-coverage.json when the request carries `?coverage=true`.
*
* Cross-engine notes (Lucee): regex backslashes are built with Chr(92);
* `<cf` inside a string is parsed as a tag, so tag patterns concatenate "<".
*/
component output="false" {

variables.bs = Chr(92);

// \bfunction\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{ — name captured in group 1
variables.scriptFnPattern = variables.bs & "bfunction" & variables.bs & "s+([A-Za-z_$][" & variables.bs & "w$]*)" & variables.bs & "s*" & variables.bs & "([^)]*" & variables.bs & ")" & variables.bs & "s*" & variables.bs & "{";

// script decision points (mirrors wheelstest.system.CodeComplexity)
variables.scriptDecision = ArrayToList([
variables.bs & "bif" & variables.bs & "b",
variables.bs & "belseif" & variables.bs & "b",
variables.bs & "belse" & variables.bs & "s+if" & variables.bs & "b",
variables.bs & "bfor" & variables.bs & "b",
variables.bs & "bwhile" & variables.bs & "b",
variables.bs & "bdo" & variables.bs & "b",
variables.bs & "bcase" & variables.bs & "b",
variables.bs & "bcatch" & variables.bs & "b",
variables.bs & "band" & variables.bs & "b",
variables.bs & "bor" & variables.bs & "b",
"&&",
variables.bs & "|" & variables.bs & "|",
variables.bs & "?"
], "|");
variables.tagDecision = "<" & "cf(?:if|elseif|loop|while|case|defaultcase|catch)" & variables.bs & "b";
variables.tagComment = "<!---.*?--->";
variables.blockComment = "/" & variables.bs & "*.*?" & variables.bs & "*/";
// No lookbehind here: Lucee routes such patterns to its ORO fallback,
// which rejects `(?<!...)` ("Sequence (?<...) not recognized"). Mask
// `://` first so a bare `//[^\r\n]*` cannot eat the tail of URL strings.
variables.urlProtocol = "://";
variables.lineComment = "//[^" & variables.bs & "r" & variables.bs & "n]*";

// Self-initializing counter, written into script function bodies. Single-
// quoted so the embedded double quotes need no escaping.
variables.scriptCounter = 'server.__wheels_cov = isDefined("server.__wheels_cov") ? server.__wheels_cov : {}; server.__wheels_cov["{ID}"] = true;';

variables.dumpPath = "/tmp/wheels-app-coverage.json";

/**
* Insert a coverage counter at the top of every script function under root.
* Returns the number of counters inserted. Originals are backed up under
* <root>/.coverage-backup/ so $revert() restores them exactly.
*/
public numeric function $instrument(required string root) {
local.count = 0;
local.paths = [];
$collectFiles(arguments.root, local.paths);
for (local.path in local.paths) {
local.rel = $relPath(arguments.root, local.path);
// Read the raw bytes first so a UTF-8 BOM survives the round-trip:
// FileRead/FileWrite alone drops it, which leaves BOM'd files with
// a one-line diff after $revert (hit by pmx app/lib/oauth2/oauth2.cfc).
local.raw = FileReadBinary(local.path);
local.hasBom = ArrayLen(local.raw) >= 3 && local.raw[1] == 239 && local.raw[2] == 187 && local.raw[3] == 191;
local.content = FileRead(local.path);
local.orig = local.content;
local.pos = 1;
while (true) {
local.m = reFind(variables.scriptFnPattern, local.content, local.pos, true);
if (local.m.pos[1] == 0) {
break;
}
local.name = Mid(local.content, local.m.pos[2], local.m.len[2]);
local.counter = Replace(variables.scriptCounter, "{ID}", local.rel & ":" & local.name);
local.insertAt = local.m.pos[1] + local.m.len[1] - 1;
local.content = Left(local.content, local.insertAt) & local.counter & Mid(local.content, local.insertAt + 1);
local.pos = local.insertAt + Len(local.counter);
local.count++;
}
if (local.content != local.orig) {
$backup(arguments.root, local.rel, local.raw);
$writePreservingBom(local.path, local.content, local.hasBom);
}
}
return local.count;
}

/** Restore the pre-instrumentation sources and remove the backup dir. */
public void function $revert(required string root) {
local.backupDir = arguments.root & "/.coverage-backup";
if (!DirectoryExists(local.backupDir)) {
return;
}
local.backups = DirectoryList(local.backupDir, true, "path");
for (local.b in local.backups) {
if (FileExists(local.b)) {
local.rel = Replace(local.b, local.backupDir, "");
if (Left(local.rel, 1) == "/") {
local.rel = Right(local.rel, Len(local.rel) - 1);
}
FileWrite(arguments.root & "/" & local.rel, FileReadBinary(local.b));
}
}
DirectoryDelete(local.backupDir, true);
}

/** Run the app test suite over HTTP (the runner dumps coverage when ?coverage=true). */
public struct function $runSuite(required numeric serverPort, boolean useTestDb = true) {
local.url = "http://localhost:" & arguments.serverPort & "/wheels/app/tests?format=json&coverage=true&useTestDB=" & (arguments.useTestDb ? "true" : "false");
local.http = new http(url = local.url, method = "GET", timeout = 1800);
local.result = local.http.send().getPrefix();
return { status = local.result.statusCode ?: "0", body = local.result.fileContent ?: "" };
}

/** Read the coverage dump written by the app test runner. */
public struct function $collect() {
if (FileExists(variables.dumpPath)) {
try {
return DeserializeJSON(FileRead(variables.dumpPath));
} catch (any e) {
return {};
}
}
return {};
}

/**
* Per-file coverage + complexity + CRAP, sorted by CRAP desc.
* coverage keys are "<rel>:<fn>"; a file is covered when any of its
* functions fired.
*/
public array function $analyze(required string root, required struct coverage) {
local.rows = [];
local.paths = [];
$collectFiles(arguments.root, local.paths);
for (local.path in local.paths) {
local.rel = $relPath(arguments.root, local.path);
local.content = FileRead(local.path);
local.complexity = $fileComplexity(local.content);
local.covered = $isCovered(arguments.coverage, local.rel);
local.uncovered = 1 - (local.covered ? 1 : 0);
local.crap = (local.complexity * local.complexity) * (local.uncovered * local.uncovered * local.uncovered) + local.complexity;
arrayAppend(local.rows, {
file = local.rel,
complexity = local.complexity,
covered = local.covered,
crap = local.crap
});
}
return $sortByCrapDesc(local.rows);
}

/** Render the CRAP report as CLI text. */
public string function $report(required array rows, numeric top = 15, numeric instrumented = 0, string suiteStatus = "") {
local.lines = [];
local.total = arrayLen(arguments.rows);
local.covered = 0;
for (local.r in arguments.rows) {
if (local.r.covered) {
local.covered++;
}
}
local.pct = local.total ? Round(100 * local.covered / local.total) : 0;
arrayAppend(local.lines, "Function coverage: " & local.covered & "/" & local.total & " files (" & local.pct & "%) - " & arguments.instrumented & " counters instrumented" & (Len(arguments.suiteStatus) ? "; suite HTTP " & arguments.suiteStatus : ""));
arrayAppend(local.lines, "");
arrayAppend(local.lines, "Top CRAP (change risk = complexity^2 x (1 - coverage)^3 + complexity):");
arrayAppend(local.lines, " CRAP comp cov file");
local.shown = 0;
for (local.r in arguments.rows) {
if (local.shown >= arguments.top) {
break;
}
if (!local.r.covered || local.r.complexity > 10) {
arrayAppend(local.lines, " " & NumberFormat(local.r.crap, "0000") & " " & NumberFormat(local.r.complexity, "000") & " " & (local.r.covered ? "yes" : "NO ") & " " & local.r.file);
local.shown++;
}
}
return ArrayToList(local.lines, Chr(10));
}

/** Cyclomatic complexity of a whole file (1 + decision points). */
private numeric function $fileComplexity(required string text) {
local.cleaned = $stripComments(arguments.text);
return 1 + arrayLen(reMatchNoCase(variables.scriptDecision, local.cleaned))
+ arrayLen(reMatchNoCase(variables.tagDecision, local.cleaned));
}

/** Strip CFML comments (tag <!--- --->, block / * * /, line //). */
private string function $stripComments(required string text) {
local.rv = reReplace(arguments.text, variables.tagComment, "", "all");
local.rv = reReplace(local.rv, variables.blockComment, "", "all");
local.rv = reReplace(local.rv, variables.urlProtocol, "__WHEELS_PROTO__", "all");
local.rv = reReplace(local.rv, variables.lineComment, "", "all");
return local.rv;
}

/** True when any coverage key belongs to this file ("<rel>:<fn>"). */
private boolean function $isCovered(required struct coverage, required string rel) {
local.prefix = arguments.rel & ":";
for (local.key in arguments.coverage) {
if (Left(local.key, Len(local.prefix)) == local.prefix) {
return true;
}
}
return false;
}

/** Recursively list .cfc/.cfm files under root, skipping tests + backup dirs. */
private void function $collectFiles(required string root, required array out) {
local.entries = DirectoryList(arguments.root, true, "path");
for (local.entry in local.entries) {
local.norm = Replace(local.entry, Chr(92), "/", "all");
local.lower = LCase(local.entry);
if ((Right(local.lower, 4) == ".cfc" || Right(local.lower, 4) == ".cfm")
&& Find("/tests/", local.norm) == 0
&& Find("/.coverage-backup/", local.norm) == 0) {
arrayAppend(arguments.out, local.entry);
}
}
}

/** Strip the root prefix (and any leading slash) for a display path. */
private string function $relPath(required string root, required string path) {
local.rv = Replace(Replace(arguments.path, arguments.root, ""), Chr(92), "/", "all");
if (Left(local.rv, 1) == "/") {
local.rv = Right(local.rv, Len(local.rv) - 1);
}
return local.rv;
}

/** Write an original's raw bytes to the backup dir (mirroring the rel path). */
private void function $backup(required string root, required string rel, required any raw) {
local.backupPath = arguments.root & "/.coverage-backup/" & arguments.rel;
local.parent = GetDirectoryFromPath(local.backupPath);
if (!DirectoryExists(local.parent)) {
DirectoryCreate(local.parent, true);
}
FileWrite(local.backupPath, arguments.raw);
}

/** Write content re-encoded as UTF-8, re-attaching a UTF-8 BOM when the original had one. */
private void function $writePreservingBom(required string path, required string content, boolean bom = false) {
local.baos = createObject("java", "java.io.ByteArrayOutputStream").init();
if (arguments.bom) {
local.baos.write(JavaCast("int", 239));
local.baos.write(JavaCast("int", 187));
local.baos.write(JavaCast("int", 191));
}
local.baos.write(CharsetDecode(arguments.content, "utf-8"));
FileWrite(arguments.path, local.baos.toByteArray());
}

/** Insertion sort by CRAP desc (no closure — cross-engine safe). */
private array function $sortByCrapDesc(required array rows) {
local.sorted = [];
for (local.r in arguments.rows) {
local.inserted = false;
for (local.j = 1; local.j <= arrayLen(local.sorted); local.j++) {
if (local.r.crap > local.sorted[local.j].crap) {
arrayInsertAt(local.sorted, local.j, local.r);
local.inserted = true;
break;
}
}
if (!local.inserted) {
arrayAppend(local.sorted, local.r);
}
}
return local.sorted;
}

}
30 changes: 28 additions & 2 deletions tools/code-quality/cfml-complexity.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
--baseline write dump {file:function:line -> complexity} JSON
--gate N --baseline F fail (exit 1) if any function exceeds N that is
new (absent from baseline) or regressed (complexity
grew vs baseline). Existing hotspots pass.
grew vs baseline). Existing hotspots pass; functions
are matched by name (file:function), so edits that
shift line numbers elsewhere in a file are fine.

Usage:
python3 cfml-complexity.py [root] --top 40
Expand Down Expand Up @@ -151,10 +153,34 @@ def main():
baseline = {}
if args.baseline and os.path.exists(args.baseline):
baseline = json.load(open(args.baseline))
# Match baseline entries by (file, function) rather than exact
# file:line:function id: inserting lines elsewhere in a file shifts
# every later function's line number, which used to make unchanged
# over-threshold functions look "new" and fail the gate spuriously.
# When a file defines several same-named functions, fall back to the
# nearest line within LINE_WINDOW.
LINE_WINDOW = 250
bindex = defaultdict(list)
for bid, bcomp in baseline.items():
parts = bid.rsplit(':', 2)
if len(parts) == 3:
bfile, bline, bfn = parts
try:
bindex[(bfile, bfn)].append((int(bline), bcomp))
except ValueError:
pass
violations = []
for r in rows:
if r['complexity'] > args.gate:
prev = baseline.get(r['id'])
prev = None
cands = bindex.get((r['rel'], r['function']))
if cands:
if len(cands) == 1:
prev = cands[0][1]
else:
nearest = min(cands, key=lambda c: abs(c[0] - r['line']))
if abs(nearest[0] - r['line']) <= LINE_WINDOW:
prev = nearest[1]
if prev is None or r['complexity'] > prev:
violations.append(r)
if violations:
Expand Down
Loading
Loading