Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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 .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
codeql_home/
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete this .gitignore

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleted in d58a5a0.

1 change: 1 addition & 0 deletions src/qtil/Qtil.qll
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions src/qtil/results/LimitResults.qll
Original file line number Diff line number Diff line change
@@ -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<MyFinding, MyEntity> {
* predicate problem(MyFinding finding, MyEntity entity) {
* entity = finding.getAnEntity()
* }
*
* string message(MyFinding finding, MyEntity entity, string remaining) {
* result = "Finding $@ has entity $@" + remaining + "."
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

documentation error: should be "Finding " + finding.getName() + " has entity $@" + remaining + "."

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d58a5a0 — the example now uses "Finding " + finding.getName() + " has entity $@" + remaining + ".".

* }
*
* string orderBy(MyEntity entity) { result = entity.getName() }
*
* int maxResults() { result = 3 }
* }
*
* module Results = LimitResults<MyFinding, MyEntity, MyConfig>;
*
* 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<FiniteType Finding, FiniteType Entity> {
/**
* 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<FiniteType Finding, FiniteType Entity, LimitResultsConfigSig<Finding, Entity> 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)
)
}
}
44 changes: 44 additions & 0 deletions test/qtil/results/Bugs.qll
Original file line number Diff line number Diff line change
@@ -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() }
}
1 change: 1 addition & 0 deletions test/qtil/results/LimitResultsTest.expected
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
| All 7 tests passed. |
119 changes: 119 additions & 0 deletions test/qtil/results/LimitResultsTest.ql
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import qtil.results.LimitResults
import qtil.testing.Qnit
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, we shouldn't use unit tests and should use a query test.

Simply import LimitResults and then expect

| BugA | "finding 1 for Bug A" | ....... |
| BugA | "finding 2 for Bug A" | .........
... etc

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Converted in d58a5a0. The test now just implements problem and message, instantiates the module, and re-exports Results::problems as a top-level query predicate. The .expected file contains the tabular rows directly.

import Bugs

module TestConfig implements LimitResultsConfigSig<Bug, BugField> {
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<Bug, BugField, TestConfig>;

/** 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")
}
}
Loading