From 290295d7b12945b72b7b92b212b144c153cae2a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:06:27 +0000 Subject: [PATCH 1/5] Initial plan From 49c6b016faad7fa7241a60a13b6c9402eccd8855 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:32:07 +0000 Subject: [PATCH 2/5] Add ranked 'and n more' reporting module (LimitResults) Co-authored-by: MichaelRFairhurst <1627771+MichaelRFairhurst@users.noreply.github.com> --- .gitignore | 1 + src/qtil/Qtil.qll | 1 + src/qtil/results/LimitResults.qll | 119 ++++++++++++++++++++ test/qtil/results/Bugs.qll | 44 ++++++++ test/qtil/results/LimitResultsTest.expected | 1 + test/qtil/results/LimitResultsTest.ql | 119 ++++++++++++++++++++ 6 files changed, 285 insertions(+) create mode 100644 .gitignore create mode 100644 src/qtil/results/LimitResults.qll create mode 100644 test/qtil/results/Bugs.qll create mode 100644 test/qtil/results/LimitResultsTest.expected create mode 100644 test/qtil/results/LimitResultsTest.ql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2886f99 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +codeql_home/ diff --git a/src/qtil/Qtil.qll b/src/qtil/Qtil.qll index 3595e30..aa8152e 100644 --- a/src/qtil/Qtil.qll +++ b/src/qtil/Qtil.qll @@ -8,6 +8,7 @@ module Qtil { import qtil.parameterization.SignaturePredicates import qtil.parameterization.SignatureTypes import qtil.graph.GraphPathSearch + import qtil.results.LimitResults import qtil.performance.GraphComparison import qtil.inheritance.Instance import qtil.inheritance.UnderlyingString diff --git a/src/qtil/results/LimitResults.qll b/src/qtil/results/LimitResults.qll new file mode 100644 index 0000000..d36cea8 --- /dev/null +++ b/src/qtil/results/LimitResults.qll @@ -0,0 +1,119 @@ +/** + * A module for limiting the number of results reported per finding to at most `N`, while appending + * an "and M more" suffix to the message when there are additional results beyond the limit. + * + * This is useful for queries that find multiple related entities per finding (such as fields, + * parameters, or call sites) but where listing all of them would be too noisy. Instead, only the + * top `N` entities are reported (ordered by a configurable key), and the message notes how many + * were omitted. + * + * ## Usage + * + * Implement the `LimitResultsConfigSig` signature and instantiate the `LimitResults` module: + * + * ```ql + * module MyConfig implements LimitResultsConfigSig { + * predicate problem(MyFinding finding, MyEntity entity) { + * entity = finding.getAnEntity() + * } + * + * string message(MyFinding finding, MyEntity entity, string remaining) { + * result = "Finding $@ has entity $@" + remaining + "." + * } + * + * string orderBy(MyEntity entity) { result = entity.getName() } + * + * int maxResults() { result = 3 } + * } + * + * module Results = LimitResults; + * + * from MyFinding finding, MyEntity entity, string message + * where Results::hasLimitedResult(finding, entity, message) + * select finding, message, entity, entity.getName() + * ``` + */ + +private import qtil.parameterization.SignatureTypes + +/** + * A signature for configuring the `LimitResults` module. + * + * Implement this signature in a module to define the relationship between findings and entities, + * the message format, the ordering of entities, and the maximum number of results to show per + * finding. + */ +signature module LimitResultsConfigSig { + /** + * The relationship between findings and their associated entities. + * + * Defines which entities are relevant to a given finding. All entities satisfying this predicate + * will be counted, but only the top `maxResults()` (ordered by `orderBy()`) will be reported. + */ + predicate problem(Finding finding, Entity entity); + + /** + * Builds the message for the finding, incorporating the "and N more" remaining string. + * + * The `remaining` parameter is either an empty string (when all entities are shown) or a string + * like `" (and 2 more)"` when some entities are omitted. The message should embed `remaining` + * appropriately, for example: `result = "Foo is broken" + remaining + "."`. + */ + bindingset[remaining] + string message(Finding finding, Entity entity, string remaining); + + /** + * The key to use when ordering entities within a finding (ascending). + * + * Entities with smaller order keys are reported first. When the total count exceeds + * `maxResults()`, only the first `maxResults()` entities by this ordering are reported. + */ + string orderBy(Entity entity); + + /** + * The maximum number of entities to report per finding. + * + * When the total number of entities for a finding exceeds this value, only the first + * `maxResults()` entities (by `orderBy()`) are reported, and the message includes a + * `" (and N more)"` suffix indicating the number of omitted entities. + */ + int maxResults(); +} + +/** + * A module that limits the number of results reported per finding, appending an "and N more" + * suffix when additional entities exist beyond the configured maximum. + * + * Use `hasLimitedResult` as the body of a `where` clause in a `select` statement. Each result + * tuple corresponds to one of the top-ranked entities for a finding, together with the formatted + * message that includes the remaining count suffix when applicable. + * + * See `LimitResultsConfigSig` for configuration details. + */ +module LimitResults Config> { + /** + * Holds for each finding and one of its top-ranked entities, providing the formatted message. + * + * At most `Config::maxResults()` entities are reported per finding. They are selected by ranking + * all entities satisfying `Config::problem(finding, entity)` in ascending order of + * `Config::orderBy(entity)`, and taking those with rank <= `Config::maxResults()`. + * + * The `message` is produced by `Config::message(finding, entity, remaining)`, where `remaining` + * is `" (and N more)"` if the total exceeds `Config::maxResults()`, or `""` otherwise. + */ + predicate hasLimitedResult(Finding finding, Entity entity, string message) { + exists(int total, int ranked, string remaining | + total = count(Entity e | Config::problem(finding, e)) and + entity = + rank[ranked](Entity e | Config::problem(finding, e) | e order by Config::orderBy(e)) and + ranked <= Config::maxResults() and + ( + total > Config::maxResults() and + remaining = " (and " + (total - Config::maxResults()) + " more)" + or + total <= Config::maxResults() and remaining = "" + ) and + message = Config::message(finding, entity, remaining) + ) + } +} diff --git a/test/qtil/results/Bugs.qll b/test/qtil/results/Bugs.qll new file mode 100644 index 0000000..c4a4ff7 --- /dev/null +++ b/test/qtil/results/Bugs.qll @@ -0,0 +1,44 @@ +/** + * Test data for LimitResultsTest.ql. + * + * Models a simple set of "bugs", each associated with a set of "fields". + * + * - BugA: 1 field (X) - fewer than maxResults + * - BugB: 3 fields (A, B, C) - exactly maxResults + * - BugC: 5 fields (A, B, C, D, E) - more than maxResults + */ +newtype TBug = + TBugA() or + TBugB() or + TBugC() + +class Bug extends TBug { + string getName() { + this = TBugA() and result = "BugA" + or + this = TBugB() and result = "BugB" + or + this = TBugC() and result = "BugC" + } + + string toString() { result = getName() } +} + +newtype TBugField = + TBugFieldPair(string bugName, string fieldName) { + bugName = "BugA" and fieldName = "X" + or + bugName = "BugB" and fieldName = ["A", "B", "C"] + or + bugName = "BugC" and fieldName = ["A", "B", "C", "D", "E"] + } + +class BugField extends TBugField { + string getBugName() { this = TBugFieldPair(result, _) } + + string getFieldName() { this = TBugFieldPair(_, result) } + + string toString() { result = getBugName() + "." + getFieldName() } + + Bug getBug() { result.getName() = getBugName() } +} diff --git a/test/qtil/results/LimitResultsTest.expected b/test/qtil/results/LimitResultsTest.expected new file mode 100644 index 0000000..2d9e6d0 --- /dev/null +++ b/test/qtil/results/LimitResultsTest.expected @@ -0,0 +1 @@ +| All 7 tests passed. | diff --git a/test/qtil/results/LimitResultsTest.ql b/test/qtil/results/LimitResultsTest.ql new file mode 100644 index 0000000..f0a096a --- /dev/null +++ b/test/qtil/results/LimitResultsTest.ql @@ -0,0 +1,119 @@ +import qtil.results.LimitResults +import qtil.testing.Qnit +import Bugs + +module TestConfig implements LimitResultsConfigSig { + predicate problem(Bug bug, BugField field) { field.getBug() = bug } + + bindingset[remaining] + string message(Bug bug, BugField field, string remaining) { + result = bug.getName() + " has field " + field.getFieldName() + remaining + } + + string orderBy(BugField field) { result = field.getFieldName() } + + int maxResults() { result = 3 } +} + +module Results = LimitResults; + +/** BugA has 1 field (X), which is fewer than maxResults=3, so all results are shown. */ +class TestBugAResultCount extends Test, Case { + override predicate run(Qnit test) { + if count(BugField f, string msg | Results::hasLimitedResult(any(Bug b | b = TBugA()), f, msg)) = + 1 + then test.pass("BugA: 1 result shown (fewer than maxResults)") + else test.fail("BugA: wrong result count") + } +} + +/** BugA shows field X with no suffix since total <= maxResults. */ +class TestBugANoSuffix extends Test, Case { + override predicate run(Qnit test) { + if + exists(BugField f, string msg | + Results::hasLimitedResult(any(Bug b | b = TBugA()), f, msg) and + f.getFieldName() = "X" and + msg = "BugA has field X" + ) + then test.pass("BugA: correct message with no suffix") + else test.fail("BugA: message incorrect") + } +} + +/** BugB has 3 fields (A, B, C), exactly maxResults=3, so all results are shown. */ +class TestBugBResultCount extends Test, Case { + override predicate run(Qnit test) { + if count(BugField f, string msg | Results::hasLimitedResult(any(Bug b | b = TBugB()), f, msg)) = + 3 + then test.pass("BugB: 3 results shown (exactly maxResults)") + else test.fail("BugB: wrong result count") + } +} + +/** BugB shows all 3 fields with no suffix since total <= maxResults. */ +class TestBugBNoSuffix extends Test, Case { + override predicate run(Qnit test) { + if + forall(string fieldName | + fieldName = ["A", "B", "C"] and + exists(BugField f | + f.getFieldName() = fieldName and + f.getBugName() = "BugB" + ) + | + exists(BugField f, string msg | + Results::hasLimitedResult(any(Bug b | b = TBugB()), f, msg) and + f.getFieldName() = fieldName and + msg = "BugB has field " + fieldName + ) + ) + then test.pass("BugB: all 3 results shown with no suffix") + else test.fail("BugB: some results have wrong message or are missing") + } +} + +/** BugC has 5 fields (A, B, C, D, E), which exceeds maxResults=3, so only 3 are shown. */ +class TestBugCResultCount extends Test, Case { + override predicate run(Qnit test) { + if count(BugField f, string msg | Results::hasLimitedResult(any(Bug b | b = TBugC()), f, msg)) = + 3 + then test.pass("BugC: 3 results shown (capped at maxResults)") + else test.fail("BugC: wrong result count") + } +} + +/** BugC shows only the first 3 fields alphabetically (A, B, C), not D or E. */ +class TestBugCTopRanked extends Test, Case { + override predicate run(Qnit test) { + if + forall(string fieldName | fieldName = ["A", "B", "C"] | + exists(BugField f | + Results::hasLimitedResult(any(Bug b | b = TBugC()), f, _) and + f.getFieldName() = fieldName and + f.getBugName() = "BugC" + ) + ) and + not exists(BugField f | + Results::hasLimitedResult(any(Bug b | b = TBugC()), f, _) and + f.getFieldName() = ["D", "E"] and + f.getBugName() = "BugC" + ) + then test.pass("BugC: top 3 alphabetical fields shown, D and E omitted") + else test.fail("BugC: wrong fields shown") + } +} + +/** BugC shows a suffix " (and 2 more)" since 5 - 3 = 2 entities are omitted. */ +class TestBugCSuffix extends Test, Case { + override predicate run(Qnit test) { + if + forall(BugField f, string msg | + Results::hasLimitedResult(any(Bug b | b = TBugC()), f, msg) + | + msg.matches("% (and 2 more)") + ) + then test.pass("BugC: all shown results have ' (and 2 more)' suffix") + else test.fail("BugC: some results have wrong suffix") + } +} From ba7bd9d9bc6497ca06124ec84c4a217bf8c5645d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:25:12 +0000 Subject: [PATCH 3/5] Address PR feedback: add query predicate, default maxResults/placeholderString Co-authored-by: MichaelRFairhurst <1627771+MichaelRFairhurst@users.noreply.github.com> --- src/qtil/results/LimitResults.qll | 74 ++++++++++++++------- test/qtil/results/LimitResultsTest.expected | 2 +- test/qtil/results/LimitResultsTest.ql | 43 +++++++++--- 3 files changed, 85 insertions(+), 34 deletions(-) diff --git a/src/qtil/results/LimitResults.qll b/src/qtil/results/LimitResults.qll index d36cea8..be78d55 100644 --- a/src/qtil/results/LimitResults.qll +++ b/src/qtil/results/LimitResults.qll @@ -4,12 +4,14 @@ * * This is useful for queries that find multiple related entities per finding (such as fields, * parameters, or call sites) but where listing all of them would be too noisy. Instead, only the - * top `N` entities are reported (ordered by a configurable key), and the message notes how many + * top `N` entities are reported (ordered by `placeholderString()`), and the message notes how many * were omitted. * * ## Usage * - * Implement the `LimitResultsConfigSig` signature and instantiate the `LimitResults` module: + * Implement the `LimitResultsConfigSig` signature and instantiate the `LimitResults` module. Only + * `problem` and `message` are required — `placeholderString` and `maxResults` have sensible + * defaults: * * ```ql * module MyConfig implements LimitResultsConfigSig { @@ -17,20 +19,21 @@ * entity = finding.getAnEntity() * } * + * bindingset[remaining] * string message(MyFinding finding, MyEntity entity, string remaining) { * result = "Finding $@ has entity $@" + remaining + "." * } - * - * string orderBy(MyEntity entity) { result = entity.getName() } - * - * int maxResults() { result = 3 } * } * * module Results = LimitResults; + * ``` + * + * The instantiated module exposes a `problems` query predicate that can be used directly as + * the output of a query without any `from`/`where`/`select` boilerplate: * - * from MyFinding finding, MyEntity entity, string message - * where Results::hasLimitedResult(finding, entity, message) - * select finding, message, entity, entity.getName() + * ```ql + * module Results = LimitResults; + * // The query predicate Results::problems(...) is automatically part of the query output. * ``` */ @@ -39,16 +42,16 @@ private import qtil.parameterization.SignatureTypes /** * A signature for configuring the `LimitResults` module. * - * Implement this signature in a module to define the relationship between findings and entities, - * the message format, the ordering of entities, and the maximum number of results to show per - * finding. + * Only `problem` and `message` must be implemented. The predicates `placeholderString` and + * `maxResults` have defaults and may be overridden. */ -signature module LimitResultsConfigSig { +signature module LimitResultsConfigSig { /** * The relationship between findings and their associated entities. * * Defines which entities are relevant to a given finding. All entities satisfying this predicate - * will be counted, but only the top `maxResults()` (ordered by `orderBy()`) will be reported. + * will be counted, but only the top `maxResults()` (ordered by `placeholderString()`) will be + * reported. */ predicate problem(Finding finding, Entity entity); @@ -63,40 +66,59 @@ signature module LimitResultsConfigSig { string message(Finding finding, Entity entity, string remaining); /** - * The key to use when ordering entities within a finding (ascending). + * The display string for an entity, also used as the ordering key (ascending). * - * Entities with smaller order keys are reported first. When the total count exceeds + * Entities with smaller placeholder strings are reported first. When the total count exceeds * `maxResults()`, only the first `maxResults()` entities by this ordering are reported. + * + * Defaults to `entity.toString()`. */ - string orderBy(Entity entity); + default string placeholderString(Entity entity) { result = entity.toString() } /** * The maximum number of entities to report per finding. * * When the total number of entities for a finding exceeds this value, only the first - * `maxResults()` entities (by `orderBy()`) are reported, and the message includes a + * `maxResults()` entities (by `placeholderString()`) are reported, and the message includes a * `" (and N more)"` suffix indicating the number of omitted entities. + * + * Defaults to `3`. */ - int maxResults(); + default int maxResults() { result = 3 } } /** * A module that limits the number of results reported per finding, appending an "and N more" * suffix when additional entities exist beyond the configured maximum. * - * Use `hasLimitedResult` as the body of a `where` clause in a `select` statement. Each result - * tuple corresponds to one of the top-ranked entities for a finding, together with the formatted - * message that includes the remaining count suffix when applicable. + * The `problems` query predicate is the main entry point for use in a query. When this module is + * instantiated as `module Results = LimitResults<...>`, the predicate `Results::problems` is + * automatically part of the query output — no `from`/`where`/`select` boilerplate is needed. * * See `LimitResultsConfigSig` for configuration details. */ -module LimitResults Config> { +module LimitResults Config> { + /** + * A query predicate that reports findings alongside one of their top-ranked entities and a + * formatted message. This is the primary way to use this module in a query. + * + * Each result tuple `(finding, msg, entity, entityStr)` corresponds to one of the top-ranked + * entities for a finding. `entityStr` is `Config::placeholderString(entity)`, suitable for use + * as the placeholder text in a `select` column alongside `entity`. + * + * At most `Config::maxResults()` entities are reported per finding. + */ + query predicate problems(Finding finding, string msg, Entity entity, string entityStr) { + hasLimitedResult(finding, entity, msg) and + entityStr = Config::placeholderString(entity) + } + /** * Holds for each finding and one of its top-ranked entities, providing the formatted message. * * At most `Config::maxResults()` entities are reported per finding. They are selected by ranking * all entities satisfying `Config::problem(finding, entity)` in ascending order of - * `Config::orderBy(entity)`, and taking those with rank <= `Config::maxResults()`. + * `Config::placeholderString(entity)`, and taking those with rank <= `Config::maxResults()`. * * The `message` is produced by `Config::message(finding, entity, remaining)`, where `remaining` * is `" (and N more)"` if the total exceeds `Config::maxResults()`, or `""` otherwise. @@ -105,7 +127,9 @@ module LimitResults Config::maxResults() and diff --git a/test/qtil/results/LimitResultsTest.expected b/test/qtil/results/LimitResultsTest.expected index 2d9e6d0..427c7d0 100644 --- a/test/qtil/results/LimitResultsTest.expected +++ b/test/qtil/results/LimitResultsTest.expected @@ -1 +1 @@ -| All 7 tests passed. | +| All 8 tests passed. | diff --git a/test/qtil/results/LimitResultsTest.ql b/test/qtil/results/LimitResultsTest.ql index f0a096a..2fc38cf 100644 --- a/test/qtil/results/LimitResultsTest.ql +++ b/test/qtil/results/LimitResultsTest.ql @@ -9,15 +9,11 @@ module TestConfig implements LimitResultsConfigSig { string message(Bug bug, BugField field, string remaining) { result = bug.getName() + " has field " + field.getFieldName() + remaining } - - string orderBy(BugField field) { result = field.getFieldName() } - - int maxResults() { result = 3 } } module Results = LimitResults; -/** BugA has 1 field (X), which is fewer than maxResults=3, so all results are shown. */ +/** BugA has 1 field (X), which is fewer than maxResults=3 (default), so all results are shown. */ class TestBugAResultCount extends Test, Case { override predicate run(Qnit test) { if count(BugField f, string msg | Results::hasLimitedResult(any(Bug b | b = TBugA()), f, msg)) = @@ -41,7 +37,7 @@ class TestBugANoSuffix extends Test, Case { } } -/** BugB has 3 fields (A, B, C), exactly maxResults=3, so all results are shown. */ +/** BugB has 3 fields (A, B, C), exactly maxResults=3 (default), so all results are shown. */ class TestBugBResultCount extends Test, Case { override predicate run(Qnit test) { if count(BugField f, string msg | Results::hasLimitedResult(any(Bug b | b = TBugB()), f, msg)) = @@ -73,7 +69,7 @@ class TestBugBNoSuffix extends Test, Case { } } -/** BugC has 5 fields (A, B, C, D, E), which exceeds maxResults=3, so only 3 are shown. */ +/** BugC has 5 fields (A, B, C, D, E), which exceeds maxResults=3 (default), so only 3 are shown. */ class TestBugCResultCount extends Test, Case { override predicate run(Qnit test) { if count(BugField f, string msg | Results::hasLimitedResult(any(Bug b | b = TBugC()), f, msg)) = @@ -83,7 +79,10 @@ class TestBugCResultCount extends Test, Case { } } -/** BugC shows only the first 3 fields alphabetically (A, B, C), not D or E. */ +/** + * BugC shows only the first 3 fields alphabetically (A, B, C), not D or E, using the default + * placeholderString ordering (toString() = "BugC.A", "BugC.B", ...). + */ class TestBugCTopRanked extends Test, Case { override predicate run(Qnit test) { if @@ -117,3 +116,31 @@ class TestBugCSuffix extends Test, Case { else test.fail("BugC: some results have wrong suffix") } } + +/** + * The `problems` query predicate returns (finding, msg, entity, entityStr) tuples, where + * entityStr is the default placeholderString (toString()). + */ +class TestProblemsQueryPredicate extends Test, Case { + override predicate run(Qnit test) { + if + // BugC: 3 problems shown, each entityStr = BugField.toString() = "BugC." + count(Bug b, string msg, BugField f, string fstr | + Results::problems(b, msg, f, fstr) and b = TBugC() + ) = 3 and + forall(Bug b, string msg, BugField f, string fstr | + Results::problems(b, msg, f, fstr) and b = TBugC() + | + fstr = "BugC." + f.getFieldName() + ) and + // BugA: 1 problem, entityStr = "BugA.X" + exists(Bug b, string msg, BugField f, string fstr | + Results::problems(b, msg, f, fstr) and + b = TBugA() and + fstr = "BugA.X" + ) + then test.pass("problems query predicate returns correct results with entityStr = toString()") + else test.fail("problems query predicate returned unexpected results") + } +} + From d58a5a07f86c7125c38de96ba8eb208488a86212 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:47:59 +0000 Subject: [PATCH 4/5] Address PR review: add orderBy/andMoreText defaults, convert to query test, update docs --- .gitignore | 1 - README.md | 29 +++++ src/qtil/results/LimitResults.qll | 66 ++++++---- test/qtil/results/LimitResultsTest.expected | 8 +- test/qtil/results/LimitResultsTest.ql | 133 +------------------- 5 files changed, 78 insertions(+), 159 deletions(-) delete mode 100644 .gitignore diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 2886f99..0000000 --- a/.gitignore +++ /dev/null @@ -1 +0,0 @@ -codeql_home/ diff --git a/README.md b/README.md index 7f94683..0ee4ee3 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,35 @@ If you wish to perform a path search such as the above, but without reporting pr use the `Qtil::GraphPathSearch` module instead, which provides an efficient search algorithm without producing a `@kind path-problem` query. +**LimitResults**: A module for capping the number of related entities reported per finding while +informing the user how many were omitted. This is useful for queries that find multiple related +entities per finding (such as fields, parameters, or call sites) where listing all of them would +produce too many results. + +Only `problem` and `message` must be implemented. `placeholderString`, `orderBy`, `maxResults`, and +`andMoreText` have sensible defaults. The instantiated module's `problems` query predicate is +automatically part of the query output: + +```ql +module MyConfig implements Qtil::LimitResultsConfigSig { + predicate problem(IncompleteInitialization init, Field f) { f = init.getField() } + + bindingset[remaining] + string message(IncompleteInitialization init, Field f, string remaining) { + result = init.getKindStr() + " does not initialize non-static data member $@" + remaining + "." + } +} + +module Results = Qtil::LimitResults; + +// Results::problems is automatically part of the query output — no from/where/select needed. +``` + +The `problems` query predicate yields `(finding, message, entity, entityStr)` tuples. At most +`maxResults()` entities (default: 3) are reported per finding, ranked by `orderBy()` (default: +`entity.toString()`). When some are omitted, `andMoreText(n)` (default: `" (and N more)"`) is +appended to the message. + ### Inheritance **Instance**: A module to make `instanceof` inheritance easier in CodeQL, by writing diff --git a/src/qtil/results/LimitResults.qll b/src/qtil/results/LimitResults.qll index be78d55..208642e 100644 --- a/src/qtil/results/LimitResults.qll +++ b/src/qtil/results/LimitResults.qll @@ -4,14 +4,15 @@ * * This is useful for queries that find multiple related entities per finding (such as fields, * parameters, or call sites) but where listing all of them would be too noisy. Instead, only the - * top `N` entities are reported (ordered by `placeholderString()`), and the message notes how many - * were omitted. + * top `N` entities are reported (ordered by `orderBy()`), and the message notes how many were + * omitted. * * ## Usage * * Implement the `LimitResultsConfigSig` signature and instantiate the `LimitResults` module. Only - * `problem` and `message` are required — `placeholderString` and `maxResults` have sensible - * defaults: + * `problem` and `message` are required — `placeholderString`, `orderBy`, `maxResults`, and + * `andMoreText` have sensible defaults. The instantiated module's `problems` query predicate is + * automatically part of the query output without any `from`/`where`/`select` boilerplate: * * ```ql * module MyConfig implements LimitResultsConfigSig { @@ -21,20 +22,12 @@ * * bindingset[remaining] * string message(MyFinding finding, MyEntity entity, string remaining) { - * result = "Finding $@ has entity $@" + remaining + "." + * result = "Finding " + finding.getName() + " has entity $@" + remaining + "." * } * } * * module Results = LimitResults; * ``` - * - * The instantiated module exposes a `problems` query predicate that can be used directly as - * the output of a query without any `from`/`where`/`select` boilerplate: - * - * ```ql - * module Results = LimitResults; - * // The query predicate Results::problems(...) is automatically part of the query output. - * ``` */ private import qtil.parameterization.SignatureTypes @@ -42,16 +35,15 @@ private import qtil.parameterization.SignatureTypes /** * A signature for configuring the `LimitResults` module. * - * Only `problem` and `message` must be implemented. The predicates `placeholderString` and - * `maxResults` have defaults and may be overridden. + * Only `problem` and `message` must be implemented. The predicates `placeholderString`, + * `orderBy`, `maxResults`, and `andMoreText` have defaults and may be overridden. */ signature module LimitResultsConfigSig { /** * The relationship between findings and their associated entities. * * Defines which entities are relevant to a given finding. All entities satisfying this predicate - * will be counted, but only the top `maxResults()` (ordered by `placeholderString()`) will be - * reported. + * will be counted, but only the top `maxResults()` (ordered by `orderBy()`) will be reported. */ predicate problem(Finding finding, Entity entity); @@ -66,25 +58,46 @@ signature module LimitResultsConfigSig Config::maxResults() and - remaining = " (and " + (total - Config::maxResults()) + " more)" + remaining = Config::andMoreText(total - Config::maxResults()) or total <= Config::maxResults() and remaining = "" ) and diff --git a/test/qtil/results/LimitResultsTest.expected b/test/qtil/results/LimitResultsTest.expected index 427c7d0..b80dad8 100644 --- a/test/qtil/results/LimitResultsTest.expected +++ b/test/qtil/results/LimitResultsTest.expected @@ -1 +1,7 @@ -| All 8 tests passed. | +| BugA | BugA has field X | BugA.X | BugA.X | +| BugB | BugB has field A | BugB.A | BugB.A | +| BugB | BugB has field B | BugB.B | BugB.B | +| BugB | BugB has field C | BugB.C | BugB.C | +| BugC | BugC has field A (and 2 more) | BugC.A | BugC.A | +| BugC | BugC has field B (and 2 more) | BugC.B | BugC.B | +| BugC | BugC has field C (and 2 more) | BugC.C | BugC.C | diff --git a/test/qtil/results/LimitResultsTest.ql b/test/qtil/results/LimitResultsTest.ql index 2fc38cf..dccf210 100644 --- a/test/qtil/results/LimitResultsTest.ql +++ b/test/qtil/results/LimitResultsTest.ql @@ -1,5 +1,4 @@ import qtil.results.LimitResults -import qtil.testing.Qnit import Bugs module TestConfig implements LimitResultsConfigSig { @@ -13,134 +12,6 @@ module TestConfig implements LimitResultsConfigSig { module Results = LimitResults; -/** BugA has 1 field (X), which is fewer than maxResults=3 (default), so all results are shown. */ -class TestBugAResultCount extends Test, Case { - override predicate run(Qnit test) { - if count(BugField f, string msg | Results::hasLimitedResult(any(Bug b | b = TBugA()), f, msg)) = - 1 - then test.pass("BugA: 1 result shown (fewer than maxResults)") - else test.fail("BugA: wrong result count") - } -} - -/** BugA shows field X with no suffix since total <= maxResults. */ -class TestBugANoSuffix extends Test, Case { - override predicate run(Qnit test) { - if - exists(BugField f, string msg | - Results::hasLimitedResult(any(Bug b | b = TBugA()), f, msg) and - f.getFieldName() = "X" and - msg = "BugA has field X" - ) - then test.pass("BugA: correct message with no suffix") - else test.fail("BugA: message incorrect") - } -} - -/** BugB has 3 fields (A, B, C), exactly maxResults=3 (default), so all results are shown. */ -class TestBugBResultCount extends Test, Case { - override predicate run(Qnit test) { - if count(BugField f, string msg | Results::hasLimitedResult(any(Bug b | b = TBugB()), f, msg)) = - 3 - then test.pass("BugB: 3 results shown (exactly maxResults)") - else test.fail("BugB: wrong result count") - } -} - -/** BugB shows all 3 fields with no suffix since total <= maxResults. */ -class TestBugBNoSuffix extends Test, Case { - override predicate run(Qnit test) { - if - forall(string fieldName | - fieldName = ["A", "B", "C"] and - exists(BugField f | - f.getFieldName() = fieldName and - f.getBugName() = "BugB" - ) - | - exists(BugField f, string msg | - Results::hasLimitedResult(any(Bug b | b = TBugB()), f, msg) and - f.getFieldName() = fieldName and - msg = "BugB has field " + fieldName - ) - ) - then test.pass("BugB: all 3 results shown with no suffix") - else test.fail("BugB: some results have wrong message or are missing") - } +query predicate problems(Bug bug, string msg, BugField field, string fieldStr) { + Results::problems(bug, msg, field, fieldStr) } - -/** BugC has 5 fields (A, B, C, D, E), which exceeds maxResults=3 (default), so only 3 are shown. */ -class TestBugCResultCount extends Test, Case { - override predicate run(Qnit test) { - if count(BugField f, string msg | Results::hasLimitedResult(any(Bug b | b = TBugC()), f, msg)) = - 3 - then test.pass("BugC: 3 results shown (capped at maxResults)") - else test.fail("BugC: wrong result count") - } -} - -/** - * BugC shows only the first 3 fields alphabetically (A, B, C), not D or E, using the default - * placeholderString ordering (toString() = "BugC.A", "BugC.B", ...). - */ -class TestBugCTopRanked extends Test, Case { - override predicate run(Qnit test) { - if - forall(string fieldName | fieldName = ["A", "B", "C"] | - exists(BugField f | - Results::hasLimitedResult(any(Bug b | b = TBugC()), f, _) and - f.getFieldName() = fieldName and - f.getBugName() = "BugC" - ) - ) and - not exists(BugField f | - Results::hasLimitedResult(any(Bug b | b = TBugC()), f, _) and - f.getFieldName() = ["D", "E"] and - f.getBugName() = "BugC" - ) - then test.pass("BugC: top 3 alphabetical fields shown, D and E omitted") - else test.fail("BugC: wrong fields shown") - } -} - -/** BugC shows a suffix " (and 2 more)" since 5 - 3 = 2 entities are omitted. */ -class TestBugCSuffix extends Test, Case { - override predicate run(Qnit test) { - if - forall(BugField f, string msg | - Results::hasLimitedResult(any(Bug b | b = TBugC()), f, msg) - | - msg.matches("% (and 2 more)") - ) - then test.pass("BugC: all shown results have ' (and 2 more)' suffix") - else test.fail("BugC: some results have wrong suffix") - } -} - -/** - * The `problems` query predicate returns (finding, msg, entity, entityStr) tuples, where - * entityStr is the default placeholderString (toString()). - */ -class TestProblemsQueryPredicate extends Test, Case { - override predicate run(Qnit test) { - if - // BugC: 3 problems shown, each entityStr = BugField.toString() = "BugC." - count(Bug b, string msg, BugField f, string fstr | - Results::problems(b, msg, f, fstr) and b = TBugC() - ) = 3 and - forall(Bug b, string msg, BugField f, string fstr | - Results::problems(b, msg, f, fstr) and b = TBugC() - | - fstr = "BugC." + f.getFieldName() - ) and - // BugA: 1 problem, entityStr = "BugA.X" - exists(Bug b, string msg, BugField f, string fstr | - Results::problems(b, msg, f, fstr) and - b = TBugA() and - fstr = "BugA.X" - ) - then test.pass("problems query predicate returns correct results with entityStr = toString()") - else test.fail("problems query predicate returned unexpected results") - } -} - From 8477200ed4a3b0a824f2d773df6d9c6e6a362935 Mon Sep 17 00:00:00 2001 From: Mike Fairhurst Date: Thu, 19 Mar 2026 12:23:09 -0700 Subject: [PATCH 5/5] Format --- src/qtil/results/LimitResults.qll | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/qtil/results/LimitResults.qll b/src/qtil/results/LimitResults.qll index 208642e..dcc158e 100644 --- a/src/qtil/results/LimitResults.qll +++ b/src/qtil/results/LimitResults.qll @@ -110,7 +110,10 @@ signature module LimitResultsConfigSig Config> { +module LimitResults< + FiniteStringableType Finding, FiniteStringableType Entity, + LimitResultsConfigSig Config> +{ /** * A query predicate that reports findings alongside one of their top-ranked entities and a * formatted message. This is the primary way to use this module in a query. @@ -140,10 +143,7 @@ module LimitResults Config::maxResults() and