this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

internal/cuetxtar: add field coverage checking

Add field coverage checking to the inline
@test runner: every field in a struct that
contains at least one @test attribute must
be either directly tested or reachable
(transitively) from a tested sibling field.

Coverage propagates via three mechanisms:
- direct identifier references in field
values
- let bindings (let X = fieldA)
- postfix aliases (field~X: ...)
- comprehensions: if a for/if clause
references two fields and one is covered,
the other is covered too

The check recurses into nested struct
literals, so inner fields of test groups
like `testCat: { a: 1 @test(); b: 2 }`
are also checked.

Opt-out via `#no-coverage` in the archive
comment header.

Seven meta-tests in
internal/cuetxtar/testdata/inline/ cover
the failure case, opt-out, fixture files,
propagation, recursion, and comprehension
grouping.

Existing txtar test files in cue/testdata/
have been updated to add @test directives
for previously uncovered inner fields.

Signed-off-by: Marcel van Lohuizen <mpvl@gmail.com>
Change-Id: I484a6312419893e8c34adb5d6b9054ff0cb59b16
Reviewed-on: https://cue.gerrithub.io/c/cue-lang/cue/+/1235980
TryBot-Result: CUEcueckoo <cueckoo@cuelang.org>
Unity-Result: CUE porcuepine <cue.porcuepine@gmail.com>
Reviewed-by: Daniel Martí <mvdan@mvdan.cc>

+580 -82
+1 -1
cue/testdata/builtins/closed.txtar
··· 4 4 }) 5 5 6 6 b: a & {x: int} @test(err, code=eval, at=x, contains="field not allowed", pos=[0:9]) // err 7 - c: a & {a: c: int} // okay (non-recursive close) 7 + c: a & {a: c: int} @test(eq, {a: {c: int, b: int}}) // okay (non-recursive close) 8 8 9 9 inDisjunctions: { 10 10 x: [string]: #Def
+5 -1
cue/testdata/builtins/export.txtar
··· 6 6 issue3972: { 7 7 // yaml uses Syntax to convert CUE to YAML. Ensure that unresolved values 8 8 // are properly handled. 9 - output: out0: "static" // has to go first 9 + // TODO(inline): the inline testing framework indicated that this field 10 + // was not used in the computation of the test result. That seems correct. 11 + // But maybe this test is then not testing what it is supposed to do. 12 + // Investigate against the original issue. 13 + output: out0: "static" @test(eq, "static") // has to go first 10 14 11 15 input: inputStatic: "static" 12 16 if false {
+1 -1
cue/testdata/compile/postfixexpr.txtar
··· 22 22 d: fn()... @test(err, code=incomplete, contains="cannot call non-concrete value fn", pos=[0:4]) 23 23 24 24 // As an expression 25 - e: #Base... & {y: int} 25 + e: #Base... & {y: int} @test(eq, {y: int, x: int}) 26 26 27 27 foo: {} @test(eq, {}) 28 28
+30 -19
cue/testdata/comprehensions/pushdown.txtar
··· 370 370 d: "d" 371 371 foobar: "ok" 372 372 } 373 + @test(eq, { 374 + a: {b: {c: {d: "d", foobar: "ok"}}} 375 + #D: {d: string} 376 + }) 373 377 } 374 378 375 379 closedCheck: success3: { ··· 388 392 d: "d" 389 393 e: "ok" 390 394 } 395 + @test(eq, { 396 + a: {b: {c: {d: "d", e: "ok"}}} 397 + #D: { 398 + d: string 399 + e: _|_ @test(err, code=incomplete, contains="closedCheck.success3.#D.e: non-concrete value string in operand to !=", pos=[382:7, 380:6]) 400 + } 401 + }) 391 402 } 392 403 393 404 emptyComprehensionIncomplete: { ··· 470 481 a: {b: {c: {}}} 471 482 E: { 472 483 e: bool 473 - f: _|_ @test(err, code=incomplete, contains="voidEliminationSuccess.derefRef1.E.f: operand e of '!' not concrete (was bool)", pos=[464:7]) 484 + f: _|_ @test(err, code=incomplete, contains="voidEliminationSuccess.derefRef1.E.f: operand e of '!' not concrete (was bool)", pos=[475:7]) 474 485 } 475 486 }) 476 487 } ··· 493 504 a: {b: {c: {}}} 494 505 E: { 495 506 e: bool 496 - f: _|_ @test(err, code=incomplete, contains="voidEliminationSuccess.derefRef2.#F.f: operand e of '!' not concrete (was bool)", pos=[487:7]) 507 + f: _|_ @test(err, code=incomplete, contains="voidEliminationSuccess.derefRef2.#F.f: operand e of '!' not concrete (was bool)", pos=[498:7]) 497 508 } 498 509 #F: { 499 510 e: bool 500 - f: _|_ @test(err, code=incomplete, contains="voidEliminationSuccess.derefRef2.#F.f: operand e of '!' not concrete (was bool)", pos=[487:7]) 511 + f: _|_ @test(err, code=incomplete, contains="voidEliminationSuccess.derefRef2.#F.f: operand e of '!' not concrete (was bool)", pos=[498:7]) 501 512 } 502 513 }) 503 514 } ··· 1344 1355 ./in.cue:361:6 1345 1356 ./in.cue:360:6 1346 1357 [incomplete] closedCheck.success3.#D.e: non-concrete value string in operand to !=: 1347 - ./in.cue:378:7 1348 - ./in.cue:376:6 1358 + ./in.cue:382:7 1359 + ./in.cue:380:6 1349 1360 [incomplete] emptyComprehensionIncomplete.b: undefined field: b: 1350 - ./in.cue:394:8 1361 + ./in.cue:402:8 1351 1362 [incomplete] voidEliminationSuccess.derefRef1.E.f: operand e of '!' not concrete (was bool): 1352 - ./in.cue:464:7 1363 + ./in.cue:472:7 1353 1364 [incomplete] voidEliminationSuccess.derefRef2.#F.f: operand e of '!' not concrete (was bool): 1354 - ./in.cue:487:7 1365 + ./in.cue:495:7 1355 1366 [incomplete] voidEliminationSuccess.derefDisj1.E: 2 errors in empty disjunction: 1356 1367 voidEliminationSuccess.derefDisj1.E.f: operand e of '!' not concrete (was bool): 1357 - ./in.cue:513:7 1368 + ./in.cue:521:7 1358 1369 voidEliminationSuccess.derefDisj1.E.h: operand g of '!' not concrete (was bool): 1359 - ./in.cue:516:7 1370 + ./in.cue:524:7 1360 1371 [incomplete] voidEliminationSuccess.derefDisj2.E: 2 errors in empty disjunction: 1361 1372 voidEliminationSuccess.derefDisj2.E.f: operand e of '!' not concrete (was bool): 1362 - ./in.cue:535:7 1373 + ./in.cue:543:7 1363 1374 voidEliminationSuccess.derefDisj2.E.h: operand g of '!' not concrete (was bool): 1364 - ./in.cue:538:7 1375 + ./in.cue:546:7 1365 1376 [incomplete] voidLookup.a.x.z: undefined field: void: 1366 - ./in.cue:603:17 1377 + ./in.cue:611:17 1367 1378 [incomplete] voidLookup.a.x.z: undefined field: void: 1368 - ./in.cue:603:17 1379 + ./in.cue:611:17 1369 1380 [incomplete] topElimination.x: non-concrete value int in operand to +: 1370 - ./in.cue:644:6 1371 - ./in.cue:641:5 1381 + ./in.cue:652:6 1382 + ./in.cue:649:5 1372 1383 [incomplete] explicitDefaultError: non-concrete value string in operand to !=: 1373 - ./in.cue:675:5 1374 - ./in.cue:673:5 1384 + ./in.cue:683:5 1385 + ./in.cue:681:5 1375 1386 [eval] structShare.err1.x.d.e: field not allowed: 1376 - ./in.cue:725:9 1387 + ./in.cue:733:9 1377 1388 [incomplete] errorPropagation: undefined field: env2: 1378 1389 ./issue2113.cue:35:19 1379 1390 [eval] issue3535.regular.foo.regular: field not allowed:
+16 -15
cue/testdata/disjunctions/errors.txtar
··· 153 153 if !doWrite { read: "somefile.txt" } 154 154 } 155 155 } 156 - // @test(eq, { 157 - // #Action: { 158 - // action: {read!: string, write?: _|_ @test(err, code=user, contains="explicit error (_|_ literal) in source", pos=[])} | {write!: string, read?: _|_ @test(err, code=user, contains="explicit error (_|_ literal) in source", pos=[])} 159 - // } 160 - // #Minio: { 161 - // container: { 162 - // action: { 163 - // write: "somefile.txt" 164 - // read?: _|_ @test(err, code=user, contains="explicit error (_|_ literal) in source (and 1 more errors)", pos=[23:13]) 165 - // } 166 - // } 167 - // doWrite: *true | bool 168 - // defaultAction: {write: "somefile.txt"} 169 - // } 170 - // }) 156 + @test(skip, hint="somehow this test doesn't work. Fix it.") 157 + @test(eq:todo, { 158 + #Action: { 159 + action: {read!: string, write?: _|_ @test(err, code=user, contains="explicit error (_|_ literal) in source", pos=[])} | {write!: string, read?: _|_ @test(err, code=user, contains="explicit error (_|_ literal) in source", pos=[])} 160 + } 161 + #Minio: { 162 + container: { 163 + action: { 164 + write: "somefile.txt" 165 + read?: _|_ @test(err, code=user, contains="explicit error (_|_ literal) in source (and 1 more errors)", pos=[23:13]) 166 + } 167 + } 168 + doWrite: *true | bool 169 + defaultAction: {write: "somefile.txt"} 170 + } 171 + }) 171 172 } 172 173 issue3599: reduced: p1: { 173 174 { a?: 1&2 } | { a: string, b?: 1&2 }
+8 -7
cue/testdata/disjunctions/incomplete.txtar
··· 8 8 a: y: "y" 9 9 test1: *a.x | 1 10 10 test2: *a.y | 1 11 + @test(eq, {a: {y: "y"}, test1: 1, test2: *"y" | 1}) 11 12 } 12 13 13 14 lookup: { ··· 103 104 } 104 105 -- out/errors.txt -- 105 106 [incomplete] lookup.y: undefined field: a: 106 - ./in.cue:16:14 107 + ./in.cue:17:14 107 108 [incomplete] lookup.allFail1: 2 errors in empty disjunction: 108 109 lookup.allFail1: undefined field: a: 109 - ./in.cue:23:14 110 + ./in.cue:24:14 110 111 lookup.allFail1: undefined field: b: 111 - ./in.cue:23:20 112 + ./in.cue:24:20 112 113 [incomplete] lookup.allFail2: 2 errors in empty disjunction: 113 114 lookup.allFail2: undefined field: a: 114 - ./in.cue:27:14 115 + ./in.cue:28:14 115 116 lookup.allFail2: undefined field: b: 116 - ./in.cue:27:20 117 + ./in.cue:28:20 117 118 [incomplete] func.ok1: 2 errors in empty disjunction: 118 119 func.ok1: error in call to encoding/yaml.MarshalStream: non-concrete argument 0: 119 - ./in.cue:35:7 120 + ./in.cue:36:7 120 121 func.ok1: error in call to encoding/yaml.Marshal: non-concrete argument 0: 121 - ./in.cue:35:31 122 + ./in.cue:36:31 122 123 -- out/eval/stats -- 123 124 Leaks: 15 124 125 Freed: 83
+5 -5
cue/testdata/eval/conjuncts.txtar
··· 50 50 in2: _in1 51 51 out: [for x in in2 {x}] 52 52 }.out 53 - // TODO(inline): allow ignoring hidden values. 54 - // @test(eq, { 55 - // @test(final) 56 - // ["foo"] 57 - // }) 53 + @test(skip, hint="TODO(inline): allow ignoring hidden values.") 54 + @test(eq:todo, { 55 + @test(final) 56 + ["foo"] 57 + }) 58 58 } 59 59 60 60 -- issue2355.cue --
+6 -6
cue/testdata/eval/disjunctions.txtar
··· 1 1 -- in.cue -- 2 - a: *1 | int 3 - aa: *1 | *2 | int 2 + a: *1 | int @test(eq, *1 | int) 3 + aa: *1 | *2 | int @test(eq, *1 | *2 | int) 4 4 5 5 b: { 6 6 name: "int" ··· 8 8 } | { 9 9 name: "str" 10 10 val: string 11 - } 11 + } @test(eq, {name: "int", val: int} | {name: "str", val: string}) 12 12 13 13 d: b & {val: 3} @test(eq, {val: 3, name: "int"}) 14 14 c: b & {name: "int", val: 3} @test(eq, {name: "int", val: 3}) ··· 432 432 import "math/bits" 433 433 434 434 indexElimination: { 435 - inStruct: x: { a: *x[0] | 0 }.a 436 - inList1: x: [x[0]][0] 437 - inList2: x: [*x[0] | 0][0] 435 + inStruct: x: { a: *x[0] | 0 }.a @test(eq, 0) 436 + inList1: x: [x[0]][0] @test(eq, _) 437 + inList2: x: [*x[0] | 0][0] @test(eq, 0) 438 438 // evalv3 strange behavior with chaining of list comprehensions, 439 439 // incorrect results when indexing the output of a for loop. 440 440 // https://cuelang.org/issue/3124
+2 -2
cue/testdata/eval/dynamic_field.txtar
··· 5 5 -- in.cue -- 6 6 a: "foo" 7 7 "\(a)": b: c: d: e 8 - e: 2 8 + e: 2 @test(eq, 2) 9 9 10 10 b: "bar" 11 11 X="\(b)": { 12 12 a: 1 13 13 } 14 - c: X 14 + c: X @test(eq, {a: 1}) 15 15 16 16 withError: { 17 17 // Panic when running cue eval with a struct used in a string interpolation.
+12 -6
cue/testdata/eval/let.txtar
··· 168 168 property: true 169 169 parent: "PARENT" 170 170 } 171 - } 172 - disabled_parent_test: {for k, v in _configs { 173 - let parent_config = (*_parent_configs[v.parent] | false) 174 - "\(k)": { 175 - "parent_config": parent_config 171 + } @test(ignore) 172 + disabled_parent_test: { 173 + for k, v in _configs { 174 + let parent_config = (*_parent_configs[v.parent] | false) 175 + "\(k)": { 176 + "parent_config": parent_config 177 + } 176 178 } 177 - }} 179 + @test(eq, { 180 + CHILD1: {parent_config: false} 181 + CHILD2: {parent_config: *true | false} 182 + }) 183 + } 178 184 179 185 -- issue2063.cue -- 180 186 import (
+1 -1
cue/testdata/eval/resolve_env.txtar
··· 10 10 }) 11 11 } 12 12 x: { 13 - d: 2 13 + d: 2 @test(eq, 2) 14 14 b: a.b.c @test(eq, int) 15 15 } 16 16 a1: y: 5
-5
cue/testdata/fulleval/012_disjunctions_of_lists.txtar
··· 1 1 -- in.cue -- 2 2 l: *[int, int] | [string, string] 3 - l1: ["a", "b"] 4 3 5 4 t1: { 6 5 l & ["c", "d"] ··· 16 15 string, 17 16 string, 18 17 ]) 19 - l1: [ 20 - "a", 21 - "b", 22 - ] 23 18 t1: { 24 19 (〈1;l〉 & [ 25 20 "c",
+5 -5
cue/testdata/fulleval/029_Issue_#94.txtar
··· 10 10 _hidden: 5 11 11 @test(eq, {opt?: 1, txt: 2, #def: 3, regular: 4, _hidden: 5}) 12 12 } 13 - comp: {for k, v in foo {"\(k)": v}} 13 + comp: { for k, v in foo {"\(k)": v} } @test(eq, {txt: 2, regular: 4}) 14 14 select: { 15 15 opt: foo.opt @test(err, code=incomplete, contains="cannot reference optional field", pos=[0:15]) 16 16 "txt": foo.txt @test(eq, 2) ··· 27 27 } 28 28 -- out/errors.txt -- 29 29 [incomplete] select.opt: cannot reference optional field: opt: 30 - ./in.cue:11:15 30 + ./in.cue:14:15 31 31 [incomplete] index.opt: cannot reference optional field: opt: 32 - ./in.cue:18:15 32 + ./in.cue:21:15 33 33 [incomplete] index.#def: undefined field: "#def": 34 - ./in.cue:20:15 34 + ./in.cue:23:15 35 35 [incomplete] index._hidden: undefined field: "_hidden": 36 - ./in.cue:22:15 36 + ./in.cue:25:15 37 37 -- out/eval/stats -- 38 38 Leaks: 0 39 39 Freed: 22
+3
cue/testdata/fulleval/049_alias_reuse_in_nested_scope.txtar
··· 5 5 #Foo: { 6 6 let X = or([for k, _ in {} {k}]) 7 7 connection: [X]: X @test(err, code=incomplete, pos=[0:11], contains="in call", args=[or]) 8 + @test(eq, { 9 + connection: _|_ @test(err, code=incomplete, contains="#Foo.X: error in call to or: empty list in call to or", pos=[2:10]) 10 + }) 8 11 } 9 12 #A: { 10 13 foo: "key"
+1 -1
cue/testdata/interpolation/scalars.txtar
··· 10 10 11 11 // two replacement characters 12 12 b2: 'a\x80\x95a' @test(eq, 'a\x80\x95a') 13 - bytes2s: "\(b2)" 13 + bytes2s: "\(b2)" @test(eq, "a�a") 14 14 bytes2b: '\(b2)' @test(eq, 'a\x80\x95a') 15 15 16 16 // preserve precision
+1 -1
cue/testdata/resolve/025_definitions.txtar
··· 18 18 19 19 foo1: #Foo 20 20 foo1: { 21 - field: 2 21 + field: 2 @test(eq, 2) 22 22 recursive: { 23 23 feild: 2 @test(err, code=eval, contains="field not allowed", pos=[0:3]) 24 24 }
+1
cue/testdata/scalars/embed.txtar
··· 102 102 other: int 103 103 } 104 104 } 105 + @test(eq, { string } | {value?: string}) 105 106 } 106 107 107 108 optionalCheck: {
+8
internal/cuetxtar/inline.go
··· 347 347 rootNames[sels[0]] = true 348 348 } 349 349 350 + // Check that every top-level field in a @test-bearing file is either 351 + // directly tested or reachable (by identifier reference) from a tested field. 352 + // Skipped when #subpath restricts which roots run, since only a subset of 353 + // the file is exercised. 354 + if subpath == "" { 355 + r.checkFieldCoverage(r.sinkOrT(), fileLevelRecords, rootNames, allRecords) 356 + } 357 + 350 358 // Run file-level @test assertions against the entire file value. 351 359 if len(fileLevelRecords) > 0 { 352 360 version := r.versionName()
+367
internal/cuetxtar/inline_coverage.go
··· 1 + // Copyright 2026 CUE Authors 2 + // 3 + // Licensed under the Apache License, Version 2.0 (the "License"); 4 + // you may not use this file except in compliance with the License. 5 + // You may obtain a copy of the License at 6 + // 7 + // http://www.apache.org/licenses/LICENSE-2.0 8 + // 9 + // Unless required by applicable law or agreed to in writing, software 10 + // distributed under the License is distributed on an "AS IS" BASIS, 11 + // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 + // See the License for the specific language governing permissions and 13 + // limitations under the License. 14 + 15 + package cuetxtar 16 + 17 + // This file implements field coverage checking for inline-assertion test 18 + // archives. Every field in any struct that contains @test attributes must be 19 + // either directly tested (has a @test) or reachable by some reference from a 20 + // tested sibling field (transitively). The check applies recursively: any 21 + // struct whose sub-fields include at least one @test is subject to the check. 22 + // 23 + // Coverage propagation within a struct follows three mechanisms: 24 + // 25 + // 1. Identifier reference: if field F references field G by its name (e.g. 26 + // `F: G & {...}`), coverage propagates from F to G. 27 + // 28 + // 2. Postfix alias: if field G declares a postfix alias (`G~X: ...`), then 29 + // a reference to `X` in a covered field covers G. 30 + // 31 + // 3. Let binding: if a let `let X = G` binds X to field G in the same scope, 32 + // then a reference to X in a covered field covers G. 33 + // 34 + // 4. Comprehension: if a comprehension (for/if clause) at the same scope 35 + // references two fields F and G, then covering F also covers G. 36 + // This is needed because a comprehension like `for k, v in items { results: 37 + // ... }` ties items and results together: testing results implicitly 38 + // exercises items. See coverage_comprehension.txtar for an example. 39 + // 40 + // Files without any @test attribute are exempt (they are fixture files). 41 + // Archives with a file-level @test that covers the whole file are exempt too. 42 + // Suppressing the check for a specific archive is possible via: 43 + // 44 + // #no-coverage 45 + // 46 + // in the archive's comment header. 47 + 48 + import ( 49 + "strings" 50 + "testing" 51 + 52 + "cuelang.org/go/cue" 53 + "cuelang.org/go/cue/ast" 54 + ) 55 + 56 + // fieldEntry describes one named field within a scope being coverage-checked. 57 + type fieldEntry struct { 58 + name string 59 + fileName string 60 + line int 61 + refs map[string]bool // identifiers referenced in the field's value 62 + valueAST ast.Expr // for recursion into nested struct literals 63 + } 64 + 65 + // checkFieldCoverage reports any field (at any nesting depth) in a 66 + // @test-bearing CUE file that is neither directly tested nor reachable, 67 + // transitively, from a directly tested sibling field within the same struct. 68 + // 69 + // fileLevelRecords are file-scope @test attributes (no field path); when any 70 + // are present the entire file is under test and all fields are implicitly 71 + // covered. 72 + // 73 + // rootNames is the map of top-level selectors that have at least one @test 74 + // attribute somewhere in their subtree. It must not be modified after this 75 + // call returns. 76 + // 77 + // allRecords is the complete set of @test records for the archive, used to 78 + // drive the recursive coverage checks into nested struct literals. 79 + func (r *inlineRunner) checkFieldCoverage(t testing.TB, fileLevelRecords []attrRecord, rootNames map[cue.Selector]bool, allRecords []attrRecord) { 80 + // Check for opt-out tag in the archive comment. 81 + for line := range strings.SplitSeq(string(r.archive.Comment), "\n") { 82 + if strings.TrimSpace(line) == "#no-coverage" { 83 + return 84 + } 85 + } 86 + 87 + // A file-level @test covers the whole evaluated value; all fields are 88 + // implicitly covered. 89 + if len(fileLevelRecords) > 0 { 90 + return 91 + } 92 + 93 + // Convert all @test record paths to string slices for the recursive checker. 94 + // File-level (empty path) and parse-error records are already handled above. 95 + var testedPaths [][]string 96 + for _, rec := range allRecords { 97 + if rec.fileLevel || rec.parseErr != nil { 98 + continue 99 + } 100 + sels := rec.path.Selectors() 101 + if len(sels) == 0 { 102 + continue 103 + } 104 + path := make([]string, len(sels)) 105 + for i, s := range sels { 106 + path[i] = s.String() 107 + } 108 + testedPaths = append(testedPaths, path) 109 + } 110 + if len(testedPaths) == 0 { 111 + return 112 + } 113 + 114 + var ( 115 + entries []fieldEntry 116 + allNames = make(map[string]bool) 117 + aliasToField = make(map[string]string) 118 + letRefs = make(map[string]map[string]bool) 119 + compRefs []map[string]bool 120 + ) 121 + for _, cf := range r.cueFiles { 122 + if !cf.hasTestAttrs { 123 + continue 124 + } 125 + parseDecls(cf.strippedAST.Decls, cf.name, &entries, allNames, aliasToField, letRefs, &compRefs) 126 + } 127 + if len(entries) == 0 { 128 + return 129 + } 130 + 131 + covered := make(map[string]bool, len(rootNames)) 132 + for sel := range rootNames { 133 + covered[sel.String()] = true 134 + } 135 + runCoverage(t, entries, allNames, aliasToField, letRefs, compRefs, covered, "", testedPaths) 136 + } 137 + 138 + // checkStructCoverage checks field coverage for a struct literal at pathStr. 139 + // subPaths are relative paths within this struct: each path's first element 140 + // is a child field name; an empty path means the struct itself is tested. 141 + func checkStructCoverage( 142 + t testing.TB, 143 + decls []ast.Decl, 144 + fileName string, 145 + pathStr string, 146 + subPaths [][]string, 147 + ) { 148 + // If any sub-path is empty, the struct itself is tested → all sub-fields 149 + // are implicitly covered. 150 + for _, sp := range subPaths { 151 + if len(sp) == 0 { 152 + return 153 + } 154 + } 155 + 156 + covered := make(map[string]bool) 157 + for _, sp := range subPaths { 158 + if len(sp) > 0 { 159 + covered[sp[0]] = true 160 + } 161 + } 162 + 163 + var ( 164 + entries []fieldEntry 165 + allNames = make(map[string]bool) 166 + aliasToField = make(map[string]string) 167 + letRefs = make(map[string]map[string]bool) 168 + compRefs []map[string]bool 169 + ) 170 + parseDecls(decls, fileName, &entries, allNames, aliasToField, letRefs, &compRefs) 171 + if len(entries) == 0 { 172 + return 173 + } 174 + runCoverage(t, entries, allNames, aliasToField, letRefs, compRefs, covered, pathStr, subPaths) 175 + } 176 + 177 + // parseDecls extracts field entries and coverage-propagation data from a slice 178 + // of AST declarations. 179 + func parseDecls( 180 + decls []ast.Decl, 181 + fileName string, 182 + entries *[]fieldEntry, 183 + allNames map[string]bool, 184 + aliasToField map[string]string, 185 + letRefs map[string]map[string]bool, 186 + compRefs *[]map[string]bool, 187 + ) { 188 + for _, decl := range decls { 189 + switch d := decl.(type) { 190 + case *ast.Comprehension: 191 + // Comprehension references form mutual-coverage groups: if any 192 + // field referenced by a comprehension is covered, all others in 193 + // that comprehension are covered too (see comment at top of file). 194 + *compRefs = append(*compRefs, collectRefs(d)) 195 + case *ast.Field: 196 + name := identStr(d.Label) 197 + if name == "" { 198 + continue 199 + } 200 + if pa := d.Alias; pa != nil { 201 + if pa.Field != nil && pa.Field.Name != "_" { 202 + aliasToField[pa.Field.Name] = name 203 + } 204 + if pa.Label != nil && pa.Label.Name != "_" { 205 + aliasToField[pa.Label.Name] = name 206 + } 207 + } 208 + *entries = append(*entries, fieldEntry{ 209 + name: name, 210 + fileName: fileName, 211 + line: d.Pos().Line(), 212 + refs: collectRefs(d.Value), 213 + valueAST: d.Value, 214 + }) 215 + allNames[name] = true 216 + case *ast.LetClause: 217 + if d.Ident != nil { 218 + letRefs[d.Ident.Name] = collectRefs(d.Expr) 219 + } 220 + } 221 + } 222 + } 223 + 224 + // runCoverage propagates coverage and reports any uncovered fields. It also 225 + // recurses into struct-literal values of covered fields. 226 + // 227 + // covered is pre-seeded with the directly-tested field names for this scope. 228 + // pathStr is the dotted path prefix used in error messages (empty at top level). 229 + // testedPaths drives recursive coverage into nested structs. 230 + func runCoverage( 231 + t testing.TB, 232 + entries []fieldEntry, 233 + allNames map[string]bool, 234 + aliasToField map[string]string, 235 + letRefs map[string]map[string]bool, 236 + compRefs []map[string]bool, 237 + covered map[string]bool, 238 + pathStr string, 239 + testedPaths [][]string, 240 + ) { 241 + // Build comprehension groups: restrict each comprehension's raw identifier 242 + // set to known field names so that covering any one member covers all others. 243 + var compGroups []map[string]bool 244 + for _, refs := range compRefs { 245 + group := make(map[string]bool) 246 + for id := range refs { 247 + if allNames[id] { 248 + group[id] = true 249 + } 250 + } 251 + if len(group) > 1 { 252 + compGroups = append(compGroups, group) 253 + } 254 + } 255 + 256 + nodes := make([]coverageNode, len(entries)) 257 + for i, e := range entries { 258 + nodes[i] = coverageNode{name: e.name, refs: e.refs} 259 + } 260 + propagateCoverage(nodes, allNames, aliasToField, letRefs, compGroups, covered) 261 + 262 + for _, e := range entries { 263 + fullName := e.name 264 + if pathStr != "" { 265 + fullName = pathStr + "." + e.name 266 + } 267 + if !covered[e.name] { 268 + t.Errorf("%s:%d: field %s is not covered: add a @test directive or reference it from a tested field", 269 + e.fileName, e.line, fullName) 270 + continue 271 + } 272 + sl, ok := e.valueAST.(*ast.StructLit) 273 + if !ok { 274 + continue 275 + } 276 + var subPaths [][]string 277 + for _, p := range testedPaths { 278 + if len(p) > 0 && p[0] == e.name { 279 + subPaths = append(subPaths, p[1:]) 280 + } 281 + } 282 + if len(subPaths) > 0 { 283 + checkStructCoverage(t, sl.Elts, e.fileName, fullName, subPaths) 284 + } 285 + } 286 + } 287 + 288 + // coverageNode carries the name and identifier set needed by propagateCoverage. 289 + type coverageNode struct { 290 + name string 291 + refs map[string]bool 292 + } 293 + 294 + // propagateCoverage runs a fixed-point BFS, spreading coverage from 295 + // already-covered fields to the fields they reference. 296 + // covered is modified in place. 297 + func propagateCoverage( 298 + nodes []coverageNode, 299 + allNames map[string]bool, 300 + aliasToField map[string]string, 301 + letRefs map[string]map[string]bool, 302 + compGroups []map[string]bool, 303 + covered map[string]bool, 304 + ) { 305 + for changed := true; changed; { 306 + changed = false 307 + for _, n := range nodes { 308 + if !covered[n.name] { 309 + continue 310 + } 311 + for ident := range n.refs { 312 + // Direct identifier reference. 313 + if allNames[ident] && !covered[ident] { 314 + covered[ident] = true 315 + changed = true 316 + } 317 + // Postfix alias: identifier resolves to a field via ~. 318 + if fieldName, ok := aliasToField[ident]; ok && !covered[fieldName] { 319 + covered[fieldName] = true 320 + changed = true 321 + } 322 + // Let binding: identifier is a let variable referencing other fields. 323 + for ref := range letRefs[ident] { 324 + if allNames[ref] && !covered[ref] { 325 + covered[ref] = true 326 + changed = true 327 + } 328 + } 329 + } 330 + } 331 + // Comprehension group propagation: if any member of a group is covered, 332 + // all members become covered. 333 + for _, group := range compGroups { 334 + anyCovered := false 335 + for name := range group { 336 + if covered[name] { 337 + anyCovered = true 338 + break 339 + } 340 + } 341 + if anyCovered { 342 + for name := range group { 343 + if !covered[name] { 344 + covered[name] = true 345 + changed = true 346 + } 347 + } 348 + } 349 + } 350 + } 351 + } 352 + 353 + // collectRefs returns the set of all identifier names referenced 354 + // anywhere within node. 355 + func collectRefs(node ast.Node) map[string]bool { 356 + if node == nil { 357 + return nil 358 + } 359 + result := make(map[string]bool) 360 + ast.Walk(node, func(n ast.Node) bool { 361 + if id, ok := n.(*ast.Ident); ok { 362 + result[id.Name] = true 363 + } 364 + return true 365 + }, nil) 366 + return result 367 + }
+4 -1
internal/cuetxtar/inlinetxtar_test.go
··· 309 309 310 310 // extractTxtarSections returns a new archive built from files in ar whose names 311 311 // start with the given prefix; the prefix is stripped from each file name. 312 + // The archive comment is copied so that archive-level directives (e.g. 313 + // #no-coverage, #subpath) written in the outer meta-test comment are applied 314 + // to the inner archive under test. 312 315 func extractTxtarSections(ar *txtar.Archive, prefix string) *txtar.Archive { 313 316 var files []txtar.File 314 317 for _, f := range ar.Files { ··· 316 319 files = append(files, txtar.File{Name: name, Data: bytes.Clone(f.Data)}) 317 320 } 318 321 } 319 - return &txtar.Archive{Files: files} 322 + return &txtar.Archive{Comment: bytes.Clone(ar.Comment), Files: files} 320 323 } 321 324 322 325 // cloneTxtarArchive returns a deep copy of ar.
+13
internal/cuetxtar/testdata/inline/coverage_comprehension.txtar
··· 1 + # Tests that fields referenced only from a comprehension are covered when any 2 + # other field in that comprehension is tested. Without this rule, 'items' would 3 + # be reported as uncovered: its name never appears in 'results: {}', only in 4 + # the comprehension body. 5 + -- in/test.cue -- 6 + items: {x: 1} 7 + results: {} @test(eq, {x: 1}) 8 + for k, v in items { 9 + results: "\(k)": v 10 + } 11 + -- out/status.txt -- 12 + update: identical to input 13 + force: identical to update
+13
internal/cuetxtar/testdata/inline/coverage_fail.txtar
··· 1 + # Tests that top-level fields in a @test-bearing file without @test and not 2 + # reachable from a tested field are reported as uncovered. 3 + -- in/test.cue -- 4 + // tested: has @test 5 + a: 1 @test(eq, 1) 6 + 7 + // b: no @test and not referenced from a tested field → must fail 8 + b: 2 9 + -- out/run/errors.txt -- 10 + test.cue:5: field b is not covered: add a @test directive or reference it from a tested field 11 + -- out/status.txt -- 12 + update: identical to input 13 + force: identical to update
+11
internal/cuetxtar/testdata/inline/coverage_file_level.txtar
··· 1 + # Tests that a file-level @test (no field path) exempts the whole file from the 2 + # field-coverage check; all fields are considered implicitly covered. 3 + -- in/test.cue -- 4 + // File-level @test covers the whole file; no per-field coverage needed. 5 + @test(eq, {a: 1, b: 2}) 6 + 7 + a: 1 8 + b: 2 9 + -- out/status.txt -- 10 + update: identical to input 11 + force: identical to update
+18
internal/cuetxtar/testdata/inline/coverage_fixture.txtar
··· 1 + # Tests that files without any @test are treated as fixture files and are 2 + # exempt from field-coverage checks. The helper fields in fixture.cue do not 3 + # need @test directives even though they are referenced by test.cue. 4 + -- in/fixture.cue -- 5 + package p 6 + 7 + // Pure fixture — no @test anywhere; its fields need not be individually tested. 8 + base: 100 9 + 10 + -- in/test.cue -- 11 + package p 12 + 13 + // test.cue has @test attributes, so it is a test file. 14 + // References base from fixture.cue to exercise coverage propagation. 15 + result: base + 1 @test(eq, 101) 16 + -- out/status.txt -- 17 + update: identical to input 18 + force: identical to update
+11
internal/cuetxtar/testdata/inline/coverage_no_coverage.txtar
··· 1 + # Tests that the #no-coverage directive in the archive comment header suppresses 2 + # the field-coverage check, allowing uncovered fields without errors. 3 + #no-coverage 4 + -- in/test.cue -- 5 + a: 1 @test(eq, 1) 6 + 7 + // b: uncovered but suppressed by #no-coverage in archive header 8 + b: 2 9 + -- out/status.txt -- 10 + update: identical to input 11 + force: identical to update
+23
internal/cuetxtar/testdata/inline/coverage_propagation.txtar
··· 1 + # Tests that coverage propagates from tested fields to fields they reference: 2 + # (1) direct identifier reference, (2) let binding, 3 + # (3) top-level comprehension. All fields become covered so the run passes. 4 + -- in/test.cue -- 5 + // (1) direct reference: a tested field references b → b is covered 6 + b: 10 7 + a: b + 1 @test(eq, 11) 8 + 9 + // (2) let binding: tested field d uses let L = c → c is covered 10 + c: 20 11 + let L = c 12 + d: L + 1 @test(eq, 21) 13 + 14 + // (3) top-level comprehension: both 'items' and 'results' appear in the 15 + // comprehension; since 'results' has @test, 'items' is covered too. 16 + items: {x: 1} 17 + results: {} @test(eq, {x: 1}) 18 + for k, v in items { 19 + results: "\(k)": v 20 + } 21 + -- out/status.txt -- 22 + update: identical to input 23 + force: identical to update
+13
internal/cuetxtar/testdata/inline/coverage_recursive.txtar
··· 1 + # Tests that coverage checking applies recursively into struct literal values. 2 + # In testCat: { a: 1 @test(); b: 2 }, b must be covered or reported as uncovered. 3 + -- in/test.cue -- 4 + // testCat has @test on a but not on b; b is not referenced from a → must fail. 5 + testCat: { 6 + a: 1 @test(eq, 1) 7 + b: 2 8 + } 9 + -- out/run/errors.txt -- 10 + test.cue:4: field testCat.b is not covered: add a @test directive or reference it from a tested field 11 + -- out/status.txt -- 12 + update: identical to input 13 + force: identical to update
+1 -5
internal/cuetxtar/testdata/inline/subpath.txtar
··· 7 7 second: 2 @test(eq, 2) 8 8 -- out/status.txt -- 9 9 update: identical to input 10 - -- out/run/errors.txt -- 11 - path first: expected 999, got 1 12 - -- out/force/test.cue -- 13 - first: 1 @test(eq, 1) 14 - second: 2 @test(eq, 2) 10 + force: identical to update