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/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..dcc158e --- /dev/null +++ b/src/qtil/results/LimitResults.qll @@ -0,0 +1,157 @@ +/** + * 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 `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`, `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 { + * predicate problem(MyFinding finding, MyEntity entity) { + * entity = finding.getAnEntity() + * } + * + * bindingset[remaining] + * string message(MyFinding finding, MyEntity entity, string remaining) { + * result = "Finding " + finding.getName() + " has entity $@" + remaining + "." + * } + * } + * + * module Results = LimitResults; + * ``` + */ + +private import qtil.parameterization.SignatureTypes + +/** + * A signature for configuring the `LimitResults` module. + * + * 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 `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 display string for an entity. + * + * Used as the `entityStr` column in the `problems` query predicate output. Also used as the + * default ordering key — see `orderBy`. + * + * Defaults to `entity.toString()`. + */ + default string placeholderString(Entity entity) { result = entity.toString() } + + /** + * 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. + * + * Defaults to `placeholderString(entity)`. + */ + default string orderBy(Entity entity) { result = placeholderString(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 the + * `andMoreText()` suffix indicating the number of omitted entities. + * + * Defaults to `3`. + */ + default int maxResults() { result = 3 } + + /** + * The suffix appended to the message when `n` entities are omitted. + * + * The parameter `n` is the number of omitted entities (i.e. `total - maxResults()`). Override + * this to customise the "and N more" text, for example to use a different locale. + * + * Defaults to `" (and N more)"`. + */ + bindingset[n] + default string andMoreText(int n) { result = " (and " + n + " more)" } +} + +/** + * 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. + * + * 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< + 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. + * + * 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()`. + * + * The `message` is produced by `Config::message(finding, entity, remaining)`, where `remaining` + * is `Config::andMoreText(n)` (with `n = total - maxResults()`) 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 = Config::andMoreText(total - Config::maxResults()) + 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..b80dad8 --- /dev/null +++ b/test/qtil/results/LimitResultsTest.expected @@ -0,0 +1,7 @@ +| 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 new file mode 100644 index 0000000..dccf210 --- /dev/null +++ b/test/qtil/results/LimitResultsTest.ql @@ -0,0 +1,17 @@ +import qtil.results.LimitResults +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 + } +} + +module Results = LimitResults; + +query predicate problems(Bug bug, string msg, BugField field, string fieldStr) { + Results::problems(bug, msg, field, fieldStr) +}