Skip to content

Commit b7364b2

Browse files
d10cCopilot
andcommitted
Preserve foreign expectations when --learn rewrites a comment
The whole-comment rewrite previously refused to touch any comment that also carried an expectation this test ignores -- for example `// $ Alert[other-query] Alert`, where `Alert[other-query]` is annotated with a different query's ID and so is invisible to the current query's postprocess. Rewriting such a comment from only the expectations this test understands would have silently dropped the foreign one, so the guard (`isFullyOwnedComment`) excluded it entirely, leaving even the owned, stale part unfixable. Make these comments surgically editable instead. `getAForeignExpectation` exposes each ignored expectation's verbatim text and column, and the rewrite now renders the union of the surviving owned expectations and the preserved foreign ones. So `// $ Alert[other-query] Alert` on a line that no longer fires becomes `// $ Alert[other-query]`: the owned `Alert` is dropped while the other query's expectation is kept untouched. The guard is renamed to `isRewritableComment` and relaxed accordingly: a comment is rewritable when it has at least one parseable expectation, none unparseable, and no comma-separated group that mixes an owned tag with an ignored one (such a group, e.g. `Alert,Source[other-query]`, would need to be split apart and is left untouched). Genuinely concurrent edits from two different queries' postprocess runs targeting the same comment remain out of scope; that needs the engine to reconcile edits and is not addressed here. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 5c81858 commit b7364b2

1 file changed

Lines changed: 75 additions & 28 deletions

File tree

‎shared/util/codeql/util/test/InlineExpectationsTest.qll‎

Lines changed: 75 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -374,23 +374,51 @@ module Make<InlineExpectationsTestSig Impl> {
374374
}
375375

376376
/**
377-
* Holds if `location` is the location of an inline expectation comment that this test fully
378-
* owns: it carries at least one expectation, none of its expectations is ignored by this test
379-
* (for example via a query ID that does not match the current query), and none is unparseable.
377+
* Holds if `location` is the location of an inline expectation comment that
378+
* `codeql test run --learn` may rewrite as a whole.
380379
*
381-
* `codeql test run --learn` may rewrite such a comment as a whole. Comments that also carry an
382-
* expectation this test cannot see must be left untouched, because rewriting them from only the
383-
* expectations this test understands would silently drop the expectations belonging to another
384-
* query that shares the same source file (for example `// $ Alert[query1] Alert[query2]`).
380+
* A comment is rewritable when it carries at least one parseable expectation, none of its
381+
* expectations is unparseable, and none mixes (in a single comma-separated group) a tag this
382+
* test understands with a tag it ignores (for example a query ID that does not match the
383+
* current query). The last condition keeps the rewrite from having to take apart a group such
384+
* as `Alert,Source[other-query]` whose parts this test treats differently; such comments are
385+
* left untouched.
386+
*
387+
* A rewritable comment may still carry whole expectations this test ignores (for example
388+
* `// $ Alert[other-query]`). Those are preserved verbatim by `getAForeignExpectation` when the
389+
* comment is rewritten, so that a `--learn` run for one query never drops an expectation that
390+
* belongs to a different query sharing the same source file.
385391
*/
386-
predicate isFullyOwnedComment(Impl::Location location) {
392+
predicate isRewritableComment(Impl::Location location) {
387393
exists(Impl::ExpectationComment comment | comment.getLocation() = location |
388-
exists(ValidTestExpectation owned | owned.getLocation() = location) and
389-
not exists(string tags |
394+
getAnExpectation(comment, _, _, _, _) and
395+
not exists(InvalidTestExpectation invalid | invalid.getLocation() = location) and
396+
not exists(string tags, string owned, string ignored |
390397
getAnExpectation(comment, _, _, tags, _) and
391-
TestImpl::tagIsIgnored(tags.splitAt(","))
392-
) and
393-
not exists(InvalidTestExpectation invalid | invalid.getLocation() = location)
398+
owned = tags.splitAt(",") and
399+
not TestImpl::tagIsIgnored(owned) and
400+
ignored = tags.splitAt(",") and
401+
TestImpl::tagIsIgnored(ignored)
402+
)
403+
)
404+
}
405+
406+
/**
407+
* Holds if the comment at `location` carries a whole expectation this test ignores (for
408+
* example one annotated with a query ID that does not match the current query), whose verbatim
409+
* text is `text` and which sits in `column` (`""` for the default column, or a named column
410+
* such as `"SPURIOUS"` / `"MISSING"`).
411+
*
412+
* `codeql test run --learn` preserves such expectations unchanged when it rewrites the comment,
413+
* because they belong to a different query that shares the same source file and this test
414+
* cannot tell whether they still hold.
415+
*/
416+
predicate getAForeignExpectation(Impl::Location location, string column, string text) {
417+
exists(Impl::ExpectationComment comment, TColumn col, string tags |
418+
comment.getLocation() = location and
419+
getAnExpectation(comment, col, text, tags, _) and
420+
column = getColumnString(col) and
421+
forall(string tag | tag = tags.splitAt(",") | TestImpl::tagIsIgnored(tag))
394422
)
395423
}
396424

@@ -1127,8 +1155,25 @@ module TestPostProcessing {
11271155
}
11281156

11291157
/**
1130-
* Holds if `column` (`""` for the default column, or `"SPURIOUS"` / `"MISSING"`) currently
1131-
* carries the expectation `text` on the comment at `commentLoc`.
1158+
* Holds if, after `--learn`, the inline expectation comment at `commentLoc` should carry the
1159+
* expectation `text` in `column` (`""` for the default column, or a named column such as
1160+
* `"SPURIOUS"` / `"MISSING"`).
1161+
*
1162+
* This combines the surviving expectations this test understands (see `learnedExpectation`)
1163+
* with the expectations it ignores (see `Test::getAForeignExpectation`), which are preserved
1164+
* verbatim so that rewriting a comment for one query never drops another query's expectation on
1165+
* the same line.
1166+
*/
1167+
private predicate desiredExpectation(TestLocation commentLoc, string column, string text) {
1168+
learnedExpectation(commentLoc, column, text)
1169+
or
1170+
Test::getAForeignExpectation(commentLoc, column, text)
1171+
}
1172+
1173+
/**
1174+
* Holds if `column` (`""` for the default column, or a named column such as `"SPURIOUS"` /
1175+
* `"MISSING"`) currently carries the expectation `text` on the comment at `commentLoc`. This
1176+
* includes expectations this test ignores, so it can be compared against `desiredExpectation`.
11321177
*/
11331178
private predicate currentExpectation(TestLocation commentLoc, string column, string text) {
11341179
exists(Test::FailureLocatable e |
@@ -1140,18 +1185,20 @@ module TestPostProcessing {
11401185
or
11411186
e instanceof Test::FalseNegativeTestExpectation and column = "MISSING"
11421187
)
1188+
or
1189+
Test::getAForeignExpectation(commentLoc, column, text)
11431190
}
11441191

11451192
/** Holds if `--learn` should change the set of expectations carried by the comment at `commentLoc`. */
11461193
private predicate commentNeedsRewrite(TestLocation commentLoc) {
11471194
exists(string column, string text |
1148-
learnedExpectation(commentLoc, column, text) and
1195+
desiredExpectation(commentLoc, column, text) and
11491196
not currentExpectation(commentLoc, column, text)
11501197
)
11511198
or
11521199
exists(string column, string text |
11531200
currentExpectation(commentLoc, column, text) and
1154-
not learnedExpectation(commentLoc, column, text)
1201+
not desiredExpectation(commentLoc, column, text)
11551202
)
11561203
}
11571204

@@ -1171,11 +1218,11 @@ module TestPostProcessing {
11711218
* column are ordered lexically, so a rewritten comment has a deterministic layout.
11721219
*/
11731220
private string renderLearnedColumn(TestLocation commentLoc, string column) {
1174-
exists(string text | learnedExpectation(commentLoc, column, text)) and
1221+
exists(string text | desiredExpectation(commentLoc, column, text)) and
11751222
exists(string joined |
11761223
joined =
11771224
concat(string text |
1178-
learnedExpectation(commentLoc, column, text)
1225+
desiredExpectation(commentLoc, column, text)
11791226
|
11801227
text, " " order by text
11811228
)
@@ -1234,11 +1281,11 @@ module TestPostProcessing {
12341281
* the surviving expectations are re-rendered. If nothing survives, the comment is deleted.
12351282
*
12361283
* The rewrite handles comments that carry several expectations across the default,
1237-
* `SPURIOUS:`, and `MISSING:` columns, but only when the test *fully owns* the comment (see
1238-
* `isFullyOwnedComment`), so it never discards an expectation belonging to a different query
1239-
* that shares the same source file. Appending a new tag by merging it into an existing comment,
1240-
* and surgically editing a comment that also carries a foreign query's expectation, are left
1241-
* for a follow-up.
1284+
* `SPURIOUS:`, and `MISSING:` columns. Expectations this test ignores (for example a tag
1285+
* annotated with a different query's ID) are preserved verbatim, so the comment keeps any
1286+
* expectation belonging to a different query that shares the same source file; see
1287+
* `isRewritableComment` and `Test::getAForeignExpectation`. Appending a new tag by merging it
1288+
* into an existing comment rather than adding a separate one is left for a follow-up.
12421289
*/
12431290
query predicate learnEdits(
12441291
string file, int line, string operation, int startColumn, int endColumn, string text
@@ -1270,13 +1317,13 @@ module TestPostProcessing {
12701317
// This subsumes the single-expectation removal and MISSING-promotion cases and additionally
12711318
// handles comments that carry several expectations across the default, `SPURIOUS:`, and
12721319
// `MISSING:` columns. The comment is replaced from its marker to the end of the line: with
1273-
// the re-rendered surviving expectations, or with the empty string when nothing survives (in
1320+
// the re-rendered desired expectations, or with the empty string when none remains (in
12741321
// which case `endColumn = 0` also trims the whitespace gap the removed comment leaves
12751322
// behind). `endColumn = 0` is the engine's "to end of line" convention, which avoids
12761323
// depending on how each extractor reports a line comment's end column (e.g. Swift reports it
12771324
// as ending at column 1 of the next line).
12781325
exists(TestLocation commentLoc, string relativePath, int sl, int sc |
1279-
Test::isFullyOwnedComment(commentLoc) and
1326+
Test::isRewritableComment(commentLoc) and
12801327
commentNeedsRewrite(commentLoc) and
12811328
parseLocationString(commentLoc.getRelativeUrl(), relativePath, sl, sc, _, _) and
12821329
file = relativePath and
@@ -1285,10 +1332,10 @@ module TestPostProcessing {
12851332
startColumn = sc and
12861333
endColumn = 0 and
12871334
(
1288-
exists(string column, string t | learnedExpectation(commentLoc, column, t)) and
1335+
exists(string column, string t | desiredExpectation(commentLoc, column, t)) and
12891336
text = renderLearnedComment(relativePath, commentLoc)
12901337
or
1291-
not exists(string column, string t | learnedExpectation(commentLoc, column, t)) and
1338+
not exists(string column, string t | desiredExpectation(commentLoc, column, t)) and
12921339
text = ""
12931340
)
12941341
)

0 commit comments

Comments
 (0)