Skip to content

Commit 49c6b01

Browse files
Add ranked 'and n more' reporting module (LimitResults)
Co-authored-by: MichaelRFairhurst <1627771+MichaelRFairhurst@users.noreply.github.com>
1 parent 290295d commit 49c6b01

6 files changed

Lines changed: 285 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
codeql_home/

src/qtil/Qtil.qll

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ module Qtil {
88
import qtil.parameterization.SignaturePredicates
99
import qtil.parameterization.SignatureTypes
1010
import qtil.graph.GraphPathSearch
11+
import qtil.results.LimitResults
1112
import qtil.performance.GraphComparison
1213
import qtil.inheritance.Instance
1314
import qtil.inheritance.UnderlyingString

src/qtil/results/LimitResults.qll

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* A module for limiting the number of results reported per finding to at most `N`, while appending
3+
* an "and M more" suffix to the message when there are additional results beyond the limit.
4+
*
5+
* This is useful for queries that find multiple related entities per finding (such as fields,
6+
* parameters, or call sites) but where listing all of them would be too noisy. Instead, only the
7+
* top `N` entities are reported (ordered by a configurable key), and the message notes how many
8+
* were omitted.
9+
*
10+
* ## Usage
11+
*
12+
* Implement the `LimitResultsConfigSig` signature and instantiate the `LimitResults` module:
13+
*
14+
* ```ql
15+
* module MyConfig implements LimitResultsConfigSig<MyFinding, MyEntity> {
16+
* predicate problem(MyFinding finding, MyEntity entity) {
17+
* entity = finding.getAnEntity()
18+
* }
19+
*
20+
* string message(MyFinding finding, MyEntity entity, string remaining) {
21+
* result = "Finding $@ has entity $@" + remaining + "."
22+
* }
23+
*
24+
* string orderBy(MyEntity entity) { result = entity.getName() }
25+
*
26+
* int maxResults() { result = 3 }
27+
* }
28+
*
29+
* module Results = LimitResults<MyFinding, MyEntity, MyConfig>;
30+
*
31+
* from MyFinding finding, MyEntity entity, string message
32+
* where Results::hasLimitedResult(finding, entity, message)
33+
* select finding, message, entity, entity.getName()
34+
* ```
35+
*/
36+
37+
private import qtil.parameterization.SignatureTypes
38+
39+
/**
40+
* A signature for configuring the `LimitResults` module.
41+
*
42+
* Implement this signature in a module to define the relationship between findings and entities,
43+
* the message format, the ordering of entities, and the maximum number of results to show per
44+
* finding.
45+
*/
46+
signature module LimitResultsConfigSig<FiniteType Finding, FiniteType Entity> {
47+
/**
48+
* The relationship between findings and their associated entities.
49+
*
50+
* Defines which entities are relevant to a given finding. All entities satisfying this predicate
51+
* will be counted, but only the top `maxResults()` (ordered by `orderBy()`) will be reported.
52+
*/
53+
predicate problem(Finding finding, Entity entity);
54+
55+
/**
56+
* Builds the message for the finding, incorporating the "and N more" remaining string.
57+
*
58+
* The `remaining` parameter is either an empty string (when all entities are shown) or a string
59+
* like `" (and 2 more)"` when some entities are omitted. The message should embed `remaining`
60+
* appropriately, for example: `result = "Foo is broken" + remaining + "."`.
61+
*/
62+
bindingset[remaining]
63+
string message(Finding finding, Entity entity, string remaining);
64+
65+
/**
66+
* The key to use when ordering entities within a finding (ascending).
67+
*
68+
* Entities with smaller order keys are reported first. When the total count exceeds
69+
* `maxResults()`, only the first `maxResults()` entities by this ordering are reported.
70+
*/
71+
string orderBy(Entity entity);
72+
73+
/**
74+
* The maximum number of entities to report per finding.
75+
*
76+
* When the total number of entities for a finding exceeds this value, only the first
77+
* `maxResults()` entities (by `orderBy()`) are reported, and the message includes a
78+
* `" (and N more)"` suffix indicating the number of omitted entities.
79+
*/
80+
int maxResults();
81+
}
82+
83+
/**
84+
* A module that limits the number of results reported per finding, appending an "and N more"
85+
* suffix when additional entities exist beyond the configured maximum.
86+
*
87+
* Use `hasLimitedResult` as the body of a `where` clause in a `select` statement. Each result
88+
* tuple corresponds to one of the top-ranked entities for a finding, together with the formatted
89+
* message that includes the remaining count suffix when applicable.
90+
*
91+
* See `LimitResultsConfigSig` for configuration details.
92+
*/
93+
module LimitResults<FiniteType Finding, FiniteType Entity, LimitResultsConfigSig<Finding, Entity> Config> {
94+
/**
95+
* Holds for each finding and one of its top-ranked entities, providing the formatted message.
96+
*
97+
* At most `Config::maxResults()` entities are reported per finding. They are selected by ranking
98+
* all entities satisfying `Config::problem(finding, entity)` in ascending order of
99+
* `Config::orderBy(entity)`, and taking those with rank <= `Config::maxResults()`.
100+
*
101+
* The `message` is produced by `Config::message(finding, entity, remaining)`, where `remaining`
102+
* is `" (and N more)"` if the total exceeds `Config::maxResults()`, or `""` otherwise.
103+
*/
104+
predicate hasLimitedResult(Finding finding, Entity entity, string message) {
105+
exists(int total, int ranked, string remaining |
106+
total = count(Entity e | Config::problem(finding, e)) and
107+
entity =
108+
rank[ranked](Entity e | Config::problem(finding, e) | e order by Config::orderBy(e)) and
109+
ranked <= Config::maxResults() and
110+
(
111+
total > Config::maxResults() and
112+
remaining = " (and " + (total - Config::maxResults()) + " more)"
113+
or
114+
total <= Config::maxResults() and remaining = ""
115+
) and
116+
message = Config::message(finding, entity, remaining)
117+
)
118+
}
119+
}

test/qtil/results/Bugs.qll

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Test data for LimitResultsTest.ql.
3+
*
4+
* Models a simple set of "bugs", each associated with a set of "fields".
5+
*
6+
* - BugA: 1 field (X) - fewer than maxResults
7+
* - BugB: 3 fields (A, B, C) - exactly maxResults
8+
* - BugC: 5 fields (A, B, C, D, E) - more than maxResults
9+
*/
10+
newtype TBug =
11+
TBugA() or
12+
TBugB() or
13+
TBugC()
14+
15+
class Bug extends TBug {
16+
string getName() {
17+
this = TBugA() and result = "BugA"
18+
or
19+
this = TBugB() and result = "BugB"
20+
or
21+
this = TBugC() and result = "BugC"
22+
}
23+
24+
string toString() { result = getName() }
25+
}
26+
27+
newtype TBugField =
28+
TBugFieldPair(string bugName, string fieldName) {
29+
bugName = "BugA" and fieldName = "X"
30+
or
31+
bugName = "BugB" and fieldName = ["A", "B", "C"]
32+
or
33+
bugName = "BugC" and fieldName = ["A", "B", "C", "D", "E"]
34+
}
35+
36+
class BugField extends TBugField {
37+
string getBugName() { this = TBugFieldPair(result, _) }
38+
39+
string getFieldName() { this = TBugFieldPair(_, result) }
40+
41+
string toString() { result = getBugName() + "." + getFieldName() }
42+
43+
Bug getBug() { result.getName() = getBugName() }
44+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
| All 7 tests passed. |
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import qtil.results.LimitResults
2+
import qtil.testing.Qnit
3+
import Bugs
4+
5+
module TestConfig implements LimitResultsConfigSig<Bug, BugField> {
6+
predicate problem(Bug bug, BugField field) { field.getBug() = bug }
7+
8+
bindingset[remaining]
9+
string message(Bug bug, BugField field, string remaining) {
10+
result = bug.getName() + " has field " + field.getFieldName() + remaining
11+
}
12+
13+
string orderBy(BugField field) { result = field.getFieldName() }
14+
15+
int maxResults() { result = 3 }
16+
}
17+
18+
module Results = LimitResults<Bug, BugField, TestConfig>;
19+
20+
/** BugA has 1 field (X), which is fewer than maxResults=3, so all results are shown. */
21+
class TestBugAResultCount extends Test, Case {
22+
override predicate run(Qnit test) {
23+
if count(BugField f, string msg | Results::hasLimitedResult(any(Bug b | b = TBugA()), f, msg)) =
24+
1
25+
then test.pass("BugA: 1 result shown (fewer than maxResults)")
26+
else test.fail("BugA: wrong result count")
27+
}
28+
}
29+
30+
/** BugA shows field X with no suffix since total <= maxResults. */
31+
class TestBugANoSuffix extends Test, Case {
32+
override predicate run(Qnit test) {
33+
if
34+
exists(BugField f, string msg |
35+
Results::hasLimitedResult(any(Bug b | b = TBugA()), f, msg) and
36+
f.getFieldName() = "X" and
37+
msg = "BugA has field X"
38+
)
39+
then test.pass("BugA: correct message with no suffix")
40+
else test.fail("BugA: message incorrect")
41+
}
42+
}
43+
44+
/** BugB has 3 fields (A, B, C), exactly maxResults=3, so all results are shown. */
45+
class TestBugBResultCount extends Test, Case {
46+
override predicate run(Qnit test) {
47+
if count(BugField f, string msg | Results::hasLimitedResult(any(Bug b | b = TBugB()), f, msg)) =
48+
3
49+
then test.pass("BugB: 3 results shown (exactly maxResults)")
50+
else test.fail("BugB: wrong result count")
51+
}
52+
}
53+
54+
/** BugB shows all 3 fields with no suffix since total <= maxResults. */
55+
class TestBugBNoSuffix extends Test, Case {
56+
override predicate run(Qnit test) {
57+
if
58+
forall(string fieldName |
59+
fieldName = ["A", "B", "C"] and
60+
exists(BugField f |
61+
f.getFieldName() = fieldName and
62+
f.getBugName() = "BugB"
63+
)
64+
|
65+
exists(BugField f, string msg |
66+
Results::hasLimitedResult(any(Bug b | b = TBugB()), f, msg) and
67+
f.getFieldName() = fieldName and
68+
msg = "BugB has field " + fieldName
69+
)
70+
)
71+
then test.pass("BugB: all 3 results shown with no suffix")
72+
else test.fail("BugB: some results have wrong message or are missing")
73+
}
74+
}
75+
76+
/** BugC has 5 fields (A, B, C, D, E), which exceeds maxResults=3, so only 3 are shown. */
77+
class TestBugCResultCount extends Test, Case {
78+
override predicate run(Qnit test) {
79+
if count(BugField f, string msg | Results::hasLimitedResult(any(Bug b | b = TBugC()), f, msg)) =
80+
3
81+
then test.pass("BugC: 3 results shown (capped at maxResults)")
82+
else test.fail("BugC: wrong result count")
83+
}
84+
}
85+
86+
/** BugC shows only the first 3 fields alphabetically (A, B, C), not D or E. */
87+
class TestBugCTopRanked extends Test, Case {
88+
override predicate run(Qnit test) {
89+
if
90+
forall(string fieldName | fieldName = ["A", "B", "C"] |
91+
exists(BugField f |
92+
Results::hasLimitedResult(any(Bug b | b = TBugC()), f, _) and
93+
f.getFieldName() = fieldName and
94+
f.getBugName() = "BugC"
95+
)
96+
) and
97+
not exists(BugField f |
98+
Results::hasLimitedResult(any(Bug b | b = TBugC()), f, _) and
99+
f.getFieldName() = ["D", "E"] and
100+
f.getBugName() = "BugC"
101+
)
102+
then test.pass("BugC: top 3 alphabetical fields shown, D and E omitted")
103+
else test.fail("BugC: wrong fields shown")
104+
}
105+
}
106+
107+
/** BugC shows a suffix " (and 2 more)" since 5 - 3 = 2 entities are omitted. */
108+
class TestBugCSuffix extends Test, Case {
109+
override predicate run(Qnit test) {
110+
if
111+
forall(BugField f, string msg |
112+
Results::hasLimitedResult(any(Bug b | b = TBugC()), f, msg)
113+
|
114+
msg.matches("% (and 2 more)")
115+
)
116+
then test.pass("BugC: all shown results have ' (and 2 more)' suffix")
117+
else test.fail("BugC: some results have wrong suffix")
118+
}
119+
}

0 commit comments

Comments
 (0)