@ttsc/evidence 0.27.0 → 0.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/native/graph.go CHANGED
@@ -68,6 +68,13 @@ func (graphRule) Check(ctx *rule.ProjectContext) {
68
68
  root,
69
69
  claimPopulationConfig(config, artifactPrisma),
70
70
  )
71
+ problems = append(
72
+ problems,
73
+ typeScriptBaseProblems(
74
+ claimPopulationConfig(config, artifactTypeScript),
75
+ typescript,
76
+ )...,
77
+ )
71
78
  problems = append(problems, markdownClaimProblems...)
72
79
  problems = append(problems, prismaClaimProblems...)
73
80
  // Governance is judged against the configuration as declared, before
@@ -163,8 +170,14 @@ func claimPopulationConfig(
163
170
  //
164
171
  // TypeScript, Prisma, and Markdown are the three claim-capable artifact kinds.
165
172
  // A healthy zero-file match is empty and therefore inactive, including when a
166
- // typo caused it. Unhealthy or unreadable populations stay active because
167
- // failed input cannot prove the selected population is empty.
173
+ // typo caused it. An unhealthy population stays active because failed input
174
+ // cannot prove the selected population is empty.
175
+ //
176
+ // Health is the whole test, and a declared root that is not a directory is one
177
+ // of the things it now answers for every kind rather than for two of them.
178
+ // Asking baseDirectoryProblem again here kept a TypeScript claim alive
179
+ // on a fact this function then had no way to report, which is how a bad root
180
+ // came to be answered with a glob diagnostic.
168
181
  func activeGraphConfig(
169
182
  config graphConfig,
170
183
  markdown map[string]*artifactInventory,
@@ -193,14 +206,19 @@ func claimIsInactive(
193
206
  claim claimSpec,
194
207
  inventories map[string]*artifactInventory,
195
208
  ) bool {
209
+ // The default arm is unreachable: decodeClaim refuses a reference-only kind
210
+ // and any kind problem returns from Check before activation runs. It stays as
211
+ // the place a fourth claim-capable kind announces itself, and such a kind
212
+ // owes this switch a case and a population loader that records its failures —
213
+ // without both it would go active and empty with nothing to say, which is the
214
+ // shape this function's own history is about.
196
215
  switch claim.Type {
197
216
  case artifactMarkdown, artifactPrisma, artifactTypeScript:
198
217
  default:
199
218
  return false
200
219
  }
201
220
  paths := matchingInventoryPaths(inventories, claim.Base, claim.Files)
202
- if !populationIsHealthy(inventories, claim.Base, paths) ||
203
- unreadableBaseProblem(claim.Base, claim.Type) != "" {
221
+ if !populationIsHealthy(inventories, claim.Base, paths) {
204
222
  return false
205
223
  }
206
224
  for _, path := range paths {
@@ -256,13 +274,17 @@ func materializeClaimStates(
256
274
  )
257
275
  }
258
276
  }
259
- if len(paths) == 0 && state.Healthy {
260
- problems = append(
261
- problems,
262
- claimLabel(claim)+" matched no "+string(claim.Type)+" files for "+describePopulation(claim.Base, claim.Files)+". Fix the globs or the root they resolve against; '*' stays within one segment, '**' crosses segments, and a bare directory is not recursive.",
263
- )
264
- }
277
+ // No claim-side empty-match diagnostic belongs here. A claim arrives
278
+ // already active, which means it either selected a host — so it matched a
279
+ // path — or its population is unhealthy, and an unhealthy one is reported
280
+ // at its own cause by the loader that failed. The message removed from this
281
+ // spot told the author to fix globs that were fine, and the only state that
282
+ // ever reached it was a TypeScript root that did not resolve, which now
283
+ // says so itself.
265
284
  hostsByID := map[string]bool{}
285
+ declaredByID := map[string]bool{}
286
+ reviewed := map[*evidenceReview]bool{}
287
+ insideCarrier := map[string]bool{}
266
288
  for _, path := range paths {
267
289
  for _, unit := range inventories[path].Units {
268
290
  if unit.Hidden != "" ||
@@ -273,16 +295,51 @@ func materializeClaimStates(
273
295
  hostsByID[unit.ID] = true
274
296
  state.Hosts = append(state.Hosts, unit)
275
297
  }
276
- state.Declarations = append(
277
- state.Declarations,
278
- inventories[path].Declarations...,
279
- )
298
+ // One physical file can occupy two of this claim's paths. A hard link is
299
+ // a second directory entry for one file and a single walk enumerates
300
+ // both, and the Prisma loader then gives both inventories the same
301
+ // declaration object rather than a copy each, because one citation is one
302
+ // obligation wherever it is read from. Appending each list would count
303
+ // that citation twice, and every duplicate and conflict rule downstream
304
+ // would then name a repair the author cannot perform: the two are one
305
+ // tag, on one line. The hosts just above are collapsed for the same
306
+ // reason.
307
+ for _, declaration := range inventories[path].Declarations {
308
+ if declaredByID[declaration.ID] {
309
+ continue
310
+ }
311
+ declaredByID[declaration.ID] = true
312
+ state.Declarations = append(state.Declarations, declaration)
313
+ }
280
314
  // Reviews travel beside declarations and never among them. They carry no
281
315
  // obligation of their own; they are looked up by host and target when a
282
- // reference demands one.
283
- state.Reviews = append(state.Reviews, inventories[path].Reviews...)
284
- if len(claim.ExclusionCarriers.Patterns) != 0 && !carrierPaths[path] {
316
+ // reference demands one. They are collapsed by object rather than by ID
317
+ // because a review has no identity of its own, and the duplication being
318
+ // removed is exactly one object reached twice. No message depends on it:
319
+ // `newReviewLedger` keeps the first review for a key and a repeat of the
320
+ // same object changes nothing it answers. What this keeps is the list
321
+ // being the union its name says it is, so a later reader that counts
322
+ // reviews rather than looking one up is not counting names of files.
323
+ for _, review := range inventories[path].Reviews {
324
+ if reviewed[review] {
325
+ continue
326
+ }
327
+ reviewed[review] = true
328
+ state.Reviews = append(state.Reviews, review)
329
+ }
330
+ if carrierPaths[path] {
285
331
  for _, declaration := range inventories[path].Declarations {
332
+ insideCarrier[declaration.ID] = true
333
+ }
334
+ }
335
+ }
336
+ // A declaration is outside the carriers only when no path carrying it is a
337
+ // carrier. Deciding that per path would put one tag in two places at once
338
+ // for a file the claim selects under two names, and report the placement of
339
+ // whichever name it read second.
340
+ if len(claim.ExclusionCarriers.Patterns) != 0 {
341
+ for _, declaration := range state.Declarations {
342
+ if !insideCarrier[declaration.ID] {
286
343
  state.OutsideCarrier[declaration.ID] = true
287
344
  }
288
345
  }
@@ -552,7 +609,7 @@ func evaluateEvidenceGraph(
552
609
  if !declaration.valid() {
553
610
  problems = append(
554
611
  problems,
555
- "Malformed @"+string(declaration.Tag)+" declaration at "+declaration.location()+" for "+context+": target and non-empty reason are mandatory. Write '@"+string(declaration.Tag)+" <target> <reason>'.+untrueTagWarning",
612
+ "Malformed @"+string(declaration.Tag)+" declaration at "+declaration.location()+" for "+context+": target and non-empty reason are mandatory. Write '@"+string(declaration.Tag)+" <target> <reason>'."+untrueTagWarning,
556
613
  )
557
614
  continue
558
615
  }
@@ -646,6 +703,21 @@ func evaluateEvidenceGraph(
646
703
  // carrier globs its message must name.
647
704
  outsideCarrier := map[string][]string{}
648
705
  outsideCarrierGlobs := map[string]string{}
706
+ // A checklist tag speaking for no selected host is a non-participation
707
+ // finding: within its reference it answers nothing, and whether that earns a
708
+ // diagnostic depends on every other obligation the same declaration can
709
+ // discharge. The eligibility comment below promises exactly that for every
710
+ // overlap — an ineligible overlap must not reject a tag already owned
711
+ // elsewhere — so the finding is recorded here and judged after the walk,
712
+ // when `answers` can say whether anything consumed the tag.
713
+ unhosted := map[string][]string{}
714
+ unhostedSelections := map[string]symbolSet{}
715
+ // answers marks a declaration that wrote at least one acknowledgement
716
+ // ledger, or was refused as an aggregate, which is that citation's own
717
+ // diagnostic. A tag the noEvidenceExclude policy refused answers nothing
718
+ // and is deliberately absent, so it can still be reported as unhosted
719
+ // where another reference is a checklist.
720
+ answers := map[string]bool{}
649
721
  for _, state := range states {
650
722
  if len(state.Paths) == 0 {
651
723
  continue
@@ -667,12 +739,26 @@ func evaluateEvidenceGraph(
667
739
  }
668
740
  single := reference.Spec.Policy.SingleEvidencePerSymbol
669
741
  unique := reference.Spec.Policy.UniqueEvidence
670
- // An empty population owes nothing and normally ends the reference
671
- // here. A healthy empty one still judges hosts under
672
- // singleEvidencePerSymbol, because every selected host then truthfully
673
- // cites zero units rather than the one it owes.
674
- if len(reference.Units) == 0 &&
675
- (!single || !state.Healthy || !reference.Healthy || len(reference.Paths) == 0) {
742
+ checklist := reference.Spec.Policy.Checklist
743
+ // An empty population ends the reference, for every policy alike.
744
+ //
745
+ // singleEvidencePerSymbol used to be excepted, on the argument that each
746
+ // selected host then truthfully cites zero units rather than the one it
747
+ // owes. The count is true and the conclusion does not follow: the
748
+ // materializer has already reported why the population is empty, and this
749
+ // added one message per host asking each of them to cite a unit that does
750
+ // not exist — a repair the population makes impossible, scaled by host
751
+ // count. It is the same derived finding the loader-failure path refuses
752
+ // to produce, and it was refused there for the same reason.
753
+ //
754
+ // The exception was also conditional on len(Paths) != 0, so the identical
755
+ // empty population was judged or skipped depending on whether any file
756
+ // happened to match, which no argument ever covered.
757
+ //
758
+ // Nothing else is lost by leaving early. Every diagnostic below reaches a
759
+ // declaration through reference.UnitsByScope, which an empty population
760
+ // leaves empty, so each one already skips every declaration it visits.
761
+ if len(reference.Units) == 0 {
676
762
  continue
677
763
  }
678
764
  acknowledged := map[string]bool{}
@@ -681,7 +767,7 @@ func evaluateEvidenceGraph(
681
767
  evidenceByHostAndScope := map[string]map[string]*evidenceDeclaration{}
682
768
  selectedHosts := map[string]*evidenceUnit{}
683
769
  evidenceUnitsByHost := map[string]map[string]bool{}
684
- if single || unique {
770
+ if single || unique || checklist {
685
771
  for _, host := range state.Hosts {
686
772
  selectedHosts[host.ID] = host
687
773
  if single {
@@ -689,6 +775,28 @@ func evaluateEvidenceGraph(
689
775
  }
690
776
  }
691
777
  }
778
+ // A checklist gives the obligation a host dimension, so every map below
779
+ // that answers "was this unit acknowledged" becomes "was it acknowledged
780
+ // *here*". The ordinary maps keep their global keys: outside a checklist
781
+ // one acknowledgement anywhere in the claim is the whole obligation, and
782
+ // that is the behavior every reference written before this option had.
783
+ acknowledgedByHost := map[string]map[string]bool{}
784
+ // aggregateByHost records the units a refused aggregate citation reached
785
+ // on a host. They are not acknowledged, and they are not reported missing
786
+ // either: the aggregate diagnostic already names them and names the one
787
+ // repair that answers both, so listing them again on the host would be
788
+ // the descendant duplication the diagnostics rule forbids.
789
+ aggregateByHost := map[string]map[string]bool{}
790
+ selectedUnitIDs := map[string]bool{}
791
+ if checklist {
792
+ for _, unit := range reference.Units {
793
+ selectedUnitIDs[unit.ID] = true
794
+ }
795
+ for _, host := range state.Hosts {
796
+ acknowledgedByHost[host.ID] = map[string]bool{}
797
+ aggregateByHost[host.ID] = map[string]bool{}
798
+ }
799
+ }
692
800
  evidenceHostsByUnit := map[string]map[string]bool{}
693
801
  if unique {
694
802
  for _, unit := range reference.Units {
@@ -747,6 +855,72 @@ func evaluateEvidenceGraph(
747
855
  )
748
856
  continue
749
857
  }
858
+ // Which acknowledgement ledgers this declaration writes into. The
859
+ // single empty key outside a checklist is the historical global
860
+ // bookkeeping; a checklist writes one entry per selected host the
861
+ // declaration speaks for.
862
+ keyHosts := []string{""}
863
+ if checklist {
864
+ keyHosts = nil
865
+ for _, hostID := range declaration.SemanticHostIDs {
866
+ if selectedHosts[hostID] != nil {
867
+ keyHosts = append(keyHosts, hostID)
868
+ }
869
+ }
870
+ if len(keyHosts) == 0 {
871
+ // Under a checklist every acknowledgement is one host's answer, so
872
+ // a tag that speaks for no selected host answers nothing here.
873
+ //
874
+ // Spreading it across the claim instead was tried and withdrawn.
875
+ // The reach it bought is the gathered exclusion ledger that
876
+ // `evidenceExcludeCarriers` exists for, and paying for it here
877
+ // meant deciding "no selected host" is the same statement as "no
878
+ // host owes this", which two ordinary Markdown shapes satisfy by
879
+ // accident: a heading whose title yields no anchor, and a path
880
+ // containing whitespace. Both report a selectable kind while naming
881
+ // no unit, so one tag discharged every item for every host in the
882
+ // claim and reported nothing. The obligation this option states is
883
+ // per host, so the answer belongs on a host.
884
+ //
885
+ // Recorded rather than reported, because "answers nothing here" is
886
+ // not yet "answers nothing". Carrier eligibility is wider than the
887
+ // host gate, so the same tag may be an ordinary sibling
888
+ // reference's gathered exclusion or an overlapping claim's own
889
+ // answer, and a hard diagnostic here left that valid configuration
890
+ // no placement to exist in. The end-of-run block reports the tag
891
+ // once nothing has consumed it.
892
+ unhosted[declaration.ID] = appendUniqueString(
893
+ unhosted[declaration.ID],
894
+ claimLabel(state.Spec)+" "+referenceLabel(reference.Spec),
895
+ )
896
+ if unhostedSelections[declaration.ID] == nil {
897
+ unhostedSelections[declaration.ID] = symbolSet{}
898
+ }
899
+ for symbol := range state.Spec.Symbols {
900
+ unhostedSelections[declaration.ID][symbol] = true
901
+ }
902
+ continue
903
+ }
904
+ }
905
+ answers[declaration.ID] = true
906
+ if checklist &&
907
+ declaration.Tag == tagEvidence &&
908
+ !selectedUnitIDs[scopeID] {
909
+ targets := make([]string, 0, len(covered))
910
+ for _, unit := range covered {
911
+ targets = append(targets, "'"+unit.Target+"'")
912
+ }
913
+ problems = append(
914
+ problems,
915
+ "Aggregate @evidence target '"+scopesByID[scopeID].Target+"' at "+declaration.location()+" in "+claimLabel(state.Spec)+" "+referenceLabel(reference.Spec)+": this reference is a checklist, so a citation answers for the item it names, and this target names a scope containing "+decimal(len(covered))+" item(s) ("+strings.Join(targets, ", ")+") rather than one of them. Cite each item this host answers for, or write @evidenceExclude on this scope when none of it applies here."+untrueTagWarning,
916
+ )
917
+ for _, unit := range covered {
918
+ for _, hostID := range keyHosts {
919
+ aggregateByHost[hostID][unit.ID] = true
920
+ }
921
+ }
922
+ continue
923
+ }
750
924
  if reference.Spec.Policy.RequireReview {
751
925
  if reviewScopes == nil {
752
926
  // Withdrawn units are absent from Population by construction, and
@@ -786,6 +960,13 @@ func evaluateEvidenceGraph(
786
960
  byScope[scopeID] = declaration
787
961
  }
788
962
  }
963
+ // These two count the whole selected subtree, which is the aggregate
964
+ // behavior a checklist takes away from a positive tag. They stay on
965
+ // `covered` rather than on `answered` because `rejectChecklistConflicts`
966
+ // refuses both options beside `checklist` at decode, and a reference
967
+ // carrying a decode problem never reaches evaluation. Relaxing that
968
+ // refusal would restore the cascade here silently, so move this to
969
+ // `answered` in the same change.
789
970
  if declaration.Tag == tagEvidence && (single || unique) {
790
971
  for _, hostID := range declaration.SemanticHostIDs {
791
972
  if selectedHosts[hostID] == nil {
@@ -815,30 +996,62 @@ func evaluateEvidenceGraph(
815
996
  var conflictingDeclaration *evidenceDeclaration
816
997
  var duplicateExclusionUnit *evidenceUnit
817
998
  var firstExclusion *evidenceDeclaration
818
- for _, unit := range covered {
999
+ // A checklist citation answers for the item it names and for nothing
1000
+ // beneath it, so it acknowledges the scope alone rather than the scope's
1001
+ // selected subtree.
1002
+ //
1003
+ // Refusing only the unselected ancestor is not enough, and measuring it
1004
+ // is the only way that shows: with the default Markdown selector the
1005
+ // file is itself an item, so one `@evidence docs/rules.md` resolved to a
1006
+ // selected unit, cascaded through the whole document, and discharged
1007
+ // every heading on that host. The option was a no-op in the first
1008
+ // configuration an adopter writes. The same hole opens for any selector
1009
+ // admitting an ancestor and a descendant together, such as `h1` beside
1010
+ // `h2`.
1011
+ //
1012
+ // `@evidenceExclude` keeps the subtree, because "none of this applies
1013
+ // here" is one decision however many items it covers, and so does every
1014
+ // reference that is not a checklist.
1015
+ answered := covered
1016
+ if checklist && declaration.Tag == tagEvidence {
1017
+ answered = []*evidenceUnit{scopesByID[scopeID]}
1018
+ }
1019
+ for _, unit := range answered {
819
1020
  acknowledged[unit.ID] = true
820
- if declaration.Tag == tagEvidence {
821
- if first := exclusionByUnit[unit.ID]; first != nil && conflictingUnit == nil {
1021
+ // Outside a checklist keyHosts is one empty string, so the key is the
1022
+ // unit and this reads exactly as it did before the host dimension
1023
+ // existed. Under a checklist two hosts excluding one item, and one
1024
+ // host citing an item another excludes, are the expected state rather
1025
+ // than a duplicate and a conflict — which is only true because the
1026
+ // key carries the host.
1027
+ for _, hostID := range keyHosts {
1028
+ key := hostID + "\x00" + unit.ID
1029
+ if acknowledgedByHost[hostID] != nil {
1030
+ acknowledgedByHost[hostID][unit.ID] = true
1031
+ }
1032
+ if declaration.Tag == tagEvidence {
1033
+ if first := exclusionByUnit[key]; first != nil && conflictingUnit == nil {
1034
+ conflictingUnit = unit
1035
+ conflictingDeclaration = first
1036
+ }
1037
+ if evidenceByUnit[key] == nil {
1038
+ evidenceByUnit[key] = declaration
1039
+ }
1040
+ continue
1041
+ }
1042
+ if first := evidenceByUnit[key]; first != nil && conflictingUnit == nil {
822
1043
  conflictingUnit = unit
823
1044
  conflictingDeclaration = first
824
1045
  }
825
- if evidenceByUnit[unit.ID] == nil {
826
- evidenceByUnit[unit.ID] = declaration
827
- }
828
- continue
829
- }
830
- if first := evidenceByUnit[unit.ID]; first != nil && conflictingUnit == nil {
831
- conflictingUnit = unit
832
- conflictingDeclaration = first
833
- }
834
- if first := exclusionByUnit[unit.ID]; first != nil {
835
- if duplicateExclusionUnit == nil {
836
- duplicateExclusionUnit = unit
837
- firstExclusion = first
1046
+ if first := exclusionByUnit[key]; first != nil {
1047
+ if duplicateExclusionUnit == nil {
1048
+ duplicateExclusionUnit = unit
1049
+ firstExclusion = first
1050
+ }
1051
+ continue
838
1052
  }
839
- continue
1053
+ exclusionByUnit[key] = declaration
840
1054
  }
841
- exclusionByUnit[unit.ID] = declaration
842
1055
  }
843
1056
  if conflictingUnit != nil {
844
1057
  evidence := declaration
@@ -859,7 +1072,10 @@ func evaluateEvidenceGraph(
859
1072
  )
860
1073
  }
861
1074
  }
862
- if !state.Healthy || !reference.Healthy || len(reference.Paths) == 0 {
1075
+ // Health alone gates the per-host policies. The path count that used to
1076
+ // sit here answered the empty-population question a second time, and a
1077
+ // reference with units has matched a path by construction.
1078
+ if !state.Healthy || !reference.Healthy {
863
1079
  continue
864
1080
  }
865
1081
  if single {
@@ -874,8 +1090,40 @@ func evaluateEvidenceGraph(
874
1090
  )
875
1091
  }
876
1092
  }
1093
+ // A checklist answers coverage per host, and that answer subsumes the
1094
+ // population-wide one: an item no host acknowledged is reported on every
1095
+ // host that owes it. With no selected host there is nobody to report it
1096
+ // on, so the population-wide question is the only one left and stays.
1097
+ perHostCoverage := checklist && len(state.Hosts) != 0
1098
+ if perHostCoverage {
1099
+ for _, host := range state.Hosts {
1100
+ missing := make([]string, 0, len(reference.Units))
1101
+ for _, unit := range reference.Units {
1102
+ if acknowledgedByHost[host.ID][unit.ID] ||
1103
+ aggregateByHost[host.ID][unit.ID] {
1104
+ continue
1105
+ }
1106
+ missing = append(missing, "'"+unit.Target+"'")
1107
+ }
1108
+ if len(missing) == 0 {
1109
+ continue
1110
+ }
1111
+ // Doing the work is named before either tag, for the reason the
1112
+ // population-wide repair below names it first: a repair offering only
1113
+ // the two tags frames an unmet item as a question of which tag to
1114
+ // write.
1115
+ repair := "Do what each item requires and cite it with @evidence on this host, or write @evidenceExclude for an item that does not apply here." + untrueTagWarning
1116
+ if reference.Spec.Policy.NoExclude {
1117
+ repair = "Do what each item requires and cite it with @evidence on this host; this reference forbids @evidenceExclude." + untrueTagWarning
1118
+ }
1119
+ problems = append(
1120
+ problems,
1121
+ "Evidence host "+host.Readable+" at "+host.location()+" in "+claimLabel(state.Spec)+" "+referenceLabel(reference.Spec)+" has not acknowledged "+decimal(len(missing))+" of "+decimal(len(reference.Units))+" checklist item(s): "+strings.Join(missing, ", ")+". "+repair,
1122
+ )
1123
+ }
1124
+ }
877
1125
  for _, unit := range reference.Units {
878
- if !acknowledged[unit.ID] {
1126
+ if !acknowledged[unit.ID] && !perHostCoverage {
879
1127
  // Building the host is named first because the other two hide it. A
880
1128
  // repair offering only the two tags frames the whole problem as which
881
1129
  // tag to write, and an unbuilt unit then leaves as an exclusion.
@@ -900,10 +1148,27 @@ func evaluateEvidenceGraph(
900
1148
  }
901
1149
  }
902
1150
  for _, id := range declarationIDs {
903
- if resolved[id] == "" || participates[id] {
1151
+ if resolved[id] == "" {
904
1152
  continue
905
1153
  }
906
1154
  declaration := declarations[id]
1155
+ // The unhosted report sits outside the participation guard, because the
1156
+ // checklist claim that recorded it also marked this declaration as
1157
+ // participating the moment it admitted the tag as eligible. Participation
1158
+ // says some claim owns the tag; only `answers` says an obligation
1159
+ // consumed it, and `uncertain` withholds the report when a failed loader
1160
+ // makes consumption unknowable, the way the ghost-finding guard below
1161
+ // withholds its own.
1162
+ if obligations := unhosted[id]; len(obligations) != 0 &&
1163
+ !answers[id] && !uncertain[id] {
1164
+ problems = append(
1165
+ problems,
1166
+ "Unhosted @"+string(declaration.Tag)+" at "+declaration.location()+" for "+strings.Join(obligations, "; ")+", target '"+displayTarget(declaration.Target)+"': a checklist acknowledgement answers for one selected host of its claim, and this declaration sits on no selected host and discharges no other obligation. Move the tag onto a host of a selected kind ("+unhostedSelections[id].names()+") in a claim that owes it."+untrueTagWarning,
1167
+ )
1168
+ }
1169
+ if participates[id] {
1170
+ continue
1171
+ }
907
1172
  context := declarationObligationContext(owners[id])
908
1173
  if obligations := outsideCarrier[id]; len(obligations) != 0 {
909
1174
  problems = append(
@@ -1023,12 +1288,19 @@ func reviewProblems(
1023
1288
  //
1024
1289
  // Two properties are load-bearing and neither is obvious.
1025
1290
  //
1026
- // The key is a *semantic* host identity, never `HostID`. `HostID` is a source
1027
- // position, so the two halves of a merged identity carry different ones: keying on
1028
- // it refused a review written on `namespace I` for a citation on `interface I`,
1291
+ // The index side reads every ledger key from the review's SemanticHostIDs. On
1292
+ // graph hosts these are semantic identities, so the two halves of a merged
1293
+ // identity share a key. Indexing by their distinct source positions instead
1294
+ // refused a review written on `namespace I` for a citation on `interface I`,
1029
1295
  // which is placement the graph elsewhere calls not worth a diagnostic and which
1030
- // `evidence/review` accepts. The two rules then disagreed about the same file.
1031
- // `model.go` says as much where it defines the field.
1296
+ // `evidence/review` accepts. An unattached Prisma review has no graph identity,
1297
+ // so it stores its synthetic file-position key in SemanticHostIDs instead.
1298
+ //
1299
+ // The lookup side normally reads the declaration's SemanticHostIDs. The
1300
+ // declaration from that Prisma run is asymmetric: it leaves them empty and
1301
+ // carries the file position in HostID, so `find` falls back there. Only lookup
1302
+ // needs a separate source-position field; the review-side fallback described no
1303
+ // producer. `model.go` states both field contracts.
1032
1304
  //
1033
1305
  // It is built once per claim rather than scanned per citation. A linear search
1034
1306
  // over every review, for every declaration, for every reference, is cubic in the
@@ -1045,7 +1317,7 @@ func newReviewLedger(reviews []*evidenceReview) *reviewLedger {
1045
1317
  if review == nil {
1046
1318
  continue
1047
1319
  }
1048
- for _, hostID := range reviewLedgerHostIDs(review) {
1320
+ for _, hostID := range review.SemanticHostIDs {
1049
1321
  key := reviewLedgerKey(hostID, review.Reviews, review.Target)
1050
1322
  if ledger.byHostAndTarget[key] == nil {
1051
1323
  ledger.byHostAndTarget[key] = review
@@ -1055,22 +1327,12 @@ func newReviewLedger(reviews []*evidenceReview) *reviewLedger {
1055
1327
  return ledger
1056
1328
  }
1057
1329
 
1058
- // reviewLedgerHostIDs is every identity a review answers on.
1059
- //
1060
- // The position identity is kept as a fallback for a carrier that has no semantic
1061
- // host at all, such as an unattached Prisma documentation run, whose declarations
1062
- // are keyed the same way.
1063
- func reviewLedgerHostIDs(review *evidenceReview) []string {
1064
- if len(review.SemanticHostIDs) != 0 {
1065
- return review.SemanticHostIDs
1066
- }
1067
- if review.HostID == "" {
1068
- return nil
1069
- }
1070
- return []string{review.HostID}
1071
- }
1072
-
1073
1330
  // find locates the review bound to one declaration's identity and target.
1331
+ //
1332
+ // The position fallback is declaration-only. A Prisma file-level exclusion is
1333
+ // a valid carrier without a semantic graph host, while the review parsed from
1334
+ // the same unattached run carries the file position as a synthetic ledger key.
1335
+ // Dropping this fallback makes that exclusion impossible to review.
1074
1336
  func (ledger *reviewLedger) find(
1075
1337
  declaration *evidenceDeclaration,
1076
1338
  ) *evidenceReview {
@@ -1179,7 +1441,7 @@ func materializeEntryReference(
1179
1441
  if failure := loader.failure(entry); failure != "" {
1180
1442
  state.Healthy = false
1181
1443
  return state, []string{
1182
- claimLabel(claim) + " " + referenceLabel(reference) + " could not read TypeScript entry '" + entry + "': " + failure + ". Fix filesystem access or the package installation; coverage cannot be evaluated from a partial entry graph.",
1444
+ claimLabel(claim) + " " + referenceLabel(reference) + " could not read TypeScript entry '" + entry + "': " + causeReason(failure) + ". Fix filesystem access or the package installation; coverage cannot be evaluated from a partial entry graph.",
1183
1445
  }
1184
1446
  }
1185
1447
  if len(state.Units) == 0 {
@@ -1305,7 +1567,7 @@ func materializePackageGlobReference(
1305
1567
  if walkProblem != "" {
1306
1568
  state.Healthy = false
1307
1569
  return state, []string{
1308
- claimLabel(claim) + " " + referenceLabel(reference) + " could not inspect TypeScript package '" + reference.Package + "': " + walkProblem + ". Fix filesystem access or reinstall the package; coverage cannot be evaluated from a partial population.",
1570
+ claimLabel(claim) + " " + referenceLabel(reference) + " could not inspect TypeScript package '" + reference.Package + "': " + causeReason(walkProblem) + ". Fix filesystem access or reinstall the package; coverage cannot be evaluated from a partial population.",
1309
1571
  }
1310
1572
  }
1311
1573
  problems := []string{}
@@ -1318,7 +1580,7 @@ func materializePackageGlobReference(
1318
1580
  state.Healthy = false
1319
1581
  problems = append(
1320
1582
  problems,
1321
- claimLabel(claim)+" "+referenceLabel(reference)+" could not read TypeScript source '"+candidate+"': "+loader.failure(candidate)+". Fix filesystem access or reinstall the package; coverage cannot be evaluated from a partial population.",
1583
+ claimLabel(claim)+" "+referenceLabel(reference)+" could not read TypeScript source '"+candidate+"': "+causeReason(loader.failure(candidate))+". Fix filesystem access or reinstall the package; coverage cannot be evaluated from a partial population.",
1322
1584
  )
1323
1585
  continue
1324
1586
  }