@ttsc/evidence 0.25.0 → 0.26.0

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.
@@ -98,6 +98,11 @@ func loadMarkdownBase(
98
98
  problems = append(problems, inventoryProblem.Message)
99
99
  }
100
100
  }
101
+ // An unreadable tag is not a health question and not a symbol question
102
+ // either: the file loaded, its units are complete, and the tag reaches no
103
+ // host whichever symbol a reference selects. The walk already refuses a
104
+ // path no configured glob takes, so reaching here is enough to report.
105
+ problems = append(problems, inventory.Unreadable...)
101
106
  return nil
102
107
  })
103
108
  if err != nil {
@@ -148,6 +153,16 @@ func scanMarkdownInventory(
148
153
  hostAtLine := make([]string, len(lines))
149
154
  hostIDAtLine := make([]string, len(lines))
150
155
  fencedAtLine := make([]bool, len(lines))
156
+ // commentAtLine marks the lines a citation or a review can live on, so the
157
+ // content digest can leave them out. A fenced block is not marked: an
158
+ // `<!-- -->` inside one hosts no tag, and its text is content of the section.
159
+ commentAtLine := make([]bool, len(lines))
160
+ // The nearest heading *unit* enclosing each line, which is not the same as its
161
+ // host: a heading may open a region without materializing a unit. Kept apart
162
+ // from hostIDAtLine because that value decides where a declaration sits, and
163
+ // widening it would move citations rather than only digests.
164
+ digestHostIDAtLine := make([]string, len(lines))
165
+ currentDigestHostID := fileUnitID
151
166
  currentHost := "file"
152
167
  currentHostID := fileUnitID
153
168
  fenceMarker := rune(0)
@@ -160,6 +175,11 @@ func scanMarkdownInventory(
160
175
  if marker, length, remainder, ok := markdownFence(line); ok {
161
176
  fencedAtLine[index] = true
162
177
  hostIDAtLine[index] = currentHostID
178
+ // Fenced content is content. It hosts no tag, so it is never excluded as a
179
+ // tag position, and leaving it unattributed would drop every code block out
180
+ // of its section's digest: rewriting the example in a cited section would
181
+ // then expire nothing.
182
+ digestHostIDAtLine[index] = currentDigestHostID
163
183
  if fenceMarker == 0 {
164
184
  fenceMarker = marker
165
185
  fenceLength = length
@@ -176,6 +196,7 @@ func scanMarkdownInventory(
176
196
  fencedAtLine[index] = true
177
197
  hostAtLine[index] = currentHost
178
198
  hostIDAtLine[index] = currentHostID
199
+ digestHostIDAtLine[index] = currentDigestHostID
179
200
  continue
180
201
  }
181
202
  if inHTMLComment {
@@ -184,6 +205,8 @@ func scanMarkdownInventory(
184
205
  }
185
206
  hostAtLine[index] = currentHost
186
207
  hostIDAtLine[index] = currentHostID
208
+ digestHostIDAtLine[index] = currentDigestHostID
209
+ commentAtLine[index] = true
187
210
  continue
188
211
  }
189
212
  if strings.HasPrefix(trimmed, "<!--") {
@@ -193,6 +216,8 @@ func scanMarkdownInventory(
193
216
  }
194
217
  hostAtLine[index] = currentHost
195
218
  hostIDAtLine[index] = currentHostID
219
+ digestHostIDAtLine[index] = currentDigestHostID
220
+ commentAtLine[index] = true
196
221
  continue
197
222
  }
198
223
  level, title, ok := markdownHeading(line)
@@ -204,6 +229,21 @@ func scanMarkdownInventory(
204
229
  headingUnitIDs[descendantLevel] = ""
205
230
  }
206
231
  }
232
+ // A heading that materializes no unit still opens a region, and that
233
+ // region's content belongs to the nearest heading unit enclosing it. An
234
+ // H5, and an H2 whose title yields no anchor, are both such headings.
235
+ // Carrying the previous unit forward instead would attribute the region to
236
+ // whatever unit the walk happened to see last, which is a sibling rather
237
+ // than an ancestor when the skipped heading is shallower: editing text
238
+ // under an anchorless H2 would then expire a review of the H3 above it,
239
+ // which does not contain that text.
240
+ currentDigestHostID = fileUnitID
241
+ for ancestorLevel := level - 1; ancestorLevel >= 1; ancestorLevel-- {
242
+ if headingUnitIDs[ancestorLevel] != "" {
243
+ currentDigestHostID = headingUnitIDs[ancestorLevel]
244
+ break
245
+ }
246
+ }
207
247
  if level <= 4 && targetablePath {
208
248
  title, anchor := markdownHeadingIdentity(title)
209
249
  if anchor == "" {
@@ -235,13 +275,17 @@ func scanMarkdownInventory(
235
275
  }
236
276
  inventory.Units = append(inventory.Units, unit)
237
277
  headingUnitIDs[level] = unit.ID
278
+ currentDigestHostID = unit.ID
238
279
  }
239
280
  }
240
281
  }
241
282
  hostAtLine[index] = currentHost
242
283
  hostIDAtLine[index] = currentHostID
284
+ digestHostIDAtLine[index] = currentDigestHostID
243
285
  }
244
286
 
287
+ reportUnreadableMarkdownTags(inventory, address.Display, lines, fencedAtLine, commentAtLine)
288
+
245
289
  sequence := 0
246
290
  for _, match := range markdownCommentPattern.FindAllStringSubmatchIndex(content, -1) {
247
291
  if len(match) < 4 {
@@ -269,10 +313,74 @@ func scanMarkdownInventory(
269
313
  Sequence: sequence,
270
314
  })
271
315
  }
316
+ for _, review := range parseReviews(comment) {
317
+ inventory.Reviews = append(inventory.Reviews, &evidenceReview{
318
+ HostID: hostIDAtLine[line-1],
319
+ SemanticHostIDs: []string{hostIDAtLine[line-1]},
320
+ Reviews: review.Reviews,
321
+ Type: artifactMarkdown,
322
+ Target: review.Target,
323
+ Fingerprint: review.Fingerprint,
324
+ Description: review.Description,
325
+ Path: address.Display,
326
+ Line: line + review.LineOffset,
327
+ })
328
+ }
272
329
  }
330
+ assignMarkdownDigests(inventory, lines, digestHostIDAtLine, commentAtLine)
273
331
  return inventory, problems
274
332
  }
275
333
 
334
+ // assignMarkdownDigests gives every unit the text it alone owns.
335
+ //
336
+ // A heading owns its own line and the body under it up to the next heading, and a
337
+ // deeper heading starts a unit of its own, so the partition is exactly what
338
+ // `digestHostIDAtLine` records while walking. Composing a subtree belongs to
339
+ // `scopeIndex`, which is why nothing is folded in here: an H2 whose own body never
340
+ // changed keeps its own digest even when an H3 beneath it did.
341
+ //
342
+ // This is where Markdown and TypeScript genuinely differ. A document can be
343
+ // partitioned into disjoint regions, so a Markdown unit's digest really is
344
+ // independent of its subtree. A declaration cannot: `interface ISale`
345
+ // textually contains the members it declares, so a TypeScript unit's digest
346
+ // covers its descendants whether anything wants it to or not.
347
+ // `evidenceUnit.Digest` records the consequence; do not carry the Markdown
348
+ // intuition across.
349
+ //
350
+ // HTML comment lines are dropped because that is where a Markdown citation and
351
+ // its review live. Leaving them in would make writing the review change the
352
+ // digest the review's own fingerprint is checked against.
353
+ func assignMarkdownDigests(
354
+ inventory *artifactInventory,
355
+ lines []string,
356
+ digestHostIDAtLine []string,
357
+ commentAtLine []bool,
358
+ ) {
359
+ owned := map[string][]string{}
360
+ for index := range lines {
361
+ id := digestHostIDAtLine[index]
362
+ if index < len(commentAtLine) && commentAtLine[index] {
363
+ continue
364
+ }
365
+ if id == "" {
366
+ continue
367
+ }
368
+ // A comment opening after prose on the same line is still a tag position:
369
+ // the declaration scan runs over the whole document, so it finds a citation
370
+ // or a review there. Only the comment span comes out, never the line, or the
371
+ // prose beside it would vanish from the digest and a real content change
372
+ // would stop expiring anything.
373
+ content := markdownCommentPattern.ReplaceAllString(
374
+ strings.TrimSuffix(lines[index], "\r"),
375
+ "",
376
+ )
377
+ owned[id] = append(owned[id], content)
378
+ }
379
+ for _, unit := range inventory.Units {
380
+ unit.Digest = contentDigest(strings.Join(owned[unit.ID], "\n"))
381
+ }
382
+ }
383
+
276
384
  // matchesConfiguredMarkdownFile reports whether a population rooted at this base
277
385
  // selects the file.
278
386
  //
@@ -421,3 +529,158 @@ func markdownSlug(title string) string {
421
529
  }
422
530
  return strings.Trim(builder.String(), "-")
423
531
  }
532
+
533
+ // reportUnreadableMarkdownTags records every tag written where this artifact
534
+ // kind cannot read one.
535
+ //
536
+ // A Markdown declaration is read from an HTML comment, so the tag renders
537
+ // invisibly and the author sees the same source either way. Written as prose it
538
+ // reaches no host and used to be discarded without a word, leaving the coverage
539
+ // diagnostic that follows to name the reference and suggest writing the
540
+ // citation the author had already written. TypeScript answers this shape and
541
+ // the Prisma bridge answers its own; this is the kind that was left silent.
542
+ //
543
+ // A fenced block is an example rather than a citation and stays silent, which
544
+ // is not a concession: this product's own documentation shows tags inside
545
+ // fences, and reporting them would fail its build. An indented code block is
546
+ // the same case in another spelling, so four leading spaces are read as code
547
+ // rather than as prose.
548
+ //
549
+ // The tag has to open its line, which is the discrimination every reader in
550
+ // this package performs, so a sentence mentioning one describes it rather than
551
+ // declaring it.
552
+ func reportUnreadableMarkdownTags(
553
+ inventory *artifactInventory,
554
+ location string,
555
+ lines []string,
556
+ fencedAtLine []bool,
557
+ commentAtLine []bool,
558
+ ) {
559
+ if inventory == nil {
560
+ return
561
+ }
562
+ rendered := false
563
+ for index, rawLine := range lines {
564
+ line := strings.TrimSuffix(rawLine, "
565
+ if opens, closes := renderedCodeEdges(line); opens || closes {
566
+ rendered = opens
567
+ continue
568
+ }
569
+ if rendered {
570
+ continue
571
+ }
572
+ if index < len(fencedAtLine) && fencedAtLine[index] {
573
+ continue
574
+ }
575
+ if index < len(commentAtLine) && commentAtLine[index] {
576
+ continue
577
+ }
578
+ if strings.HasPrefix(line, " ") || strings.HasPrefix(line, " ") {
579
+ continue
580
+ }
581
+ trimmed := markdownLineContent(line)
582
+ if tag, _, found := declarationLine(trimmed); found {
583
+ inventory.Unreadable = append(
584
+ inventory.Unreadable,
585
+ unreadableMarkdownProblem("@"+string(tag), location, index+1),
586
+ )
587
+ continue
588
+ }
589
+ if reviews, _, opened := reviewLine(trimmed); opened {
590
+ inventory.Unreadable = append(
591
+ inventory.Unreadable,
592
+ unreadableMarkdownProblem(reviewMarkerFor(reviews), location, index+1),
593
+ )
594
+ }
595
+ }
596
+ }
597
+
598
+ // markdownLineContent drops the markers that carry a line rather than say
599
+ // anything.
600
+ //
601
+ // A citation written as a bullet or inside a quote is the same mistake as one
602
+ // written bare, and an author reaching for a list is if anything more likely
603
+ // than one writing a lone paragraph. Reading the line without its marker is
604
+ // what lets the report name them, while the tag still has to be the first
605
+ // content on the line, so a sentence mentioning one goes on describing it.
606
+ func markdownLineContent(line string) string {
607
+ content := strings.TrimSpace(line)
608
+ for {
609
+ stripped := strings.TrimSpace(strings.TrimPrefix(content, ">"))
610
+ if stripped != content {
611
+ content = stripped
612
+ continue
613
+ }
614
+ if marker := markdownListMarker(content); marker != 0 {
615
+ content = strings.TrimSpace(content[marker:])
616
+ continue
617
+ }
618
+ return content
619
+ }
620
+ }
621
+
622
+ // markdownListMarker reports the length of a leading list marker, or zero.
623
+ func markdownListMarker(content string) int {
624
+ for _, bullet := range []string{"- ", "* ", "+ "} {
625
+ if strings.HasPrefix(content, bullet) {
626
+ return len(bullet)
627
+ }
628
+ }
629
+ digits := 0
630
+ for digits < len(content) && content[digits] >= '0' && content[digits] <= '9' {
631
+ digits++
632
+ }
633
+ if digits == 0 || digits+1 >= len(content) {
634
+ return 0
635
+ }
636
+ if punctuation := content[digits]; punctuation != '.' && punctuation != ')' {
637
+ return 0
638
+ }
639
+ if content[digits+1] != ' ' {
640
+ return 0
641
+ }
642
+ return digits + 2
643
+ }
644
+
645
+ // renderedCodeEdges reports whether a line opens or closes a block that renders
646
+ // as code without being a fence.
647
+ //
648
+ // A documentation site shows examples through more than one syntax. An MDX page
649
+ // passes a template literal to a component, and an HTML page uses `<pre>`; both
650
+ // render as code, so both are examples in the sense a fence is, and the repair
651
+ // this diagnostic names would delete the example from the rendered page rather
652
+ // than fix anything. Only the two edges are recognized, because a page that
653
+ // opens one and never closes it is a page whose own build fails first.
654
+ func renderedCodeEdges(line string) (bool, bool) {
655
+ lowered := strings.ToLower(line)
656
+ switch {
657
+ case strings.Contains(lowered, "<pre"):
658
+ return !strings.Contains(lowered, "</pre>"), strings.Contains(lowered, "</pre>")
659
+ case strings.Contains(lowered, "</pre>"):
660
+ return false, true
661
+ case strings.Contains(line, "={`"):
662
+ return !strings.Contains(line, "`}"), strings.Contains(line, "`}")
663
+ case strings.Contains(line, "`}"):
664
+ return false, true
665
+ }
666
+ return false, false
667
+ }
668
+
669
+ // unreadableMarkdownProblem names the position and the move that fixes it.
670
+ func unreadableMarkdownProblem(tag string, location string, line int) string {
671
+ return "Unreadable " + tag + " at " + location + ":" + decimal(line) +
672
+ ": a Markdown declaration is read from an HTML comment, and this line is prose, so nothing reads the tag." +
673
+ " Wrap it as '<!-- " + tag + " <target> " + unreadableMarkdownField(tag) + " -->'."
674
+ }
675
+
676
+ // unreadableMarkdownField names what follows a target for this tag.
677
+ //
678
+ // A review carries a description of what was checked rather than a reason, and
679
+ // every other review diagnostic in this package says so. One template for both
680
+ // families would tell an author to write the wrong field.
681
+ func unreadableMarkdownField(tag string) string {
682
+ if strings.HasSuffix(tag, "Review") {
683
+ return "<what you checked>"
684
+ }
685
+ return "<reason>"
686
+ }
package/native/model.go CHANGED
@@ -15,6 +15,27 @@ const documentedRuleName = "evidence/documented"
15
15
 
16
16
  const todoRuleName = "evidence/todo"
17
17
 
18
+ const reviewRuleName = "evidence/review"
19
+
20
+ // Appended to every diagnostic a tag could silence, because the cheapest way to
21
+ // clear one of these is to write a tag that is not true, and the reader is
22
+ // usually an agent whose next action is conditioned on this text alone.
23
+ //
24
+ // It names the motive rather than the truth value. A tag written to pass may
25
+ // happen to be true and is still wrong, and "do not write a false tag" invites
26
+ // exactly that reading.
27
+ //
28
+ // "Leave standing" rather than "write", because writing is only one of the ways
29
+ // a falsehood clears a diagnostic. A tag can also be moved onto a declaration
30
+ // that does not own its target, or kept when the conflicting one is deleted.
31
+ // One verb covers all three, so every diagnostic carries the same sentence and
32
+ // the reader meets one rule rather than three variants of it.
33
+ const untrueTagWarning = " Never leave an untrue tag standing just to pass this check; it removes the error, not the problem."
34
+
35
+ // The same warning for the two review tags, which are written about a check
36
+ // rather than about a host.
37
+ const untrueReviewWarning = " Never leave an untrue review standing just to pass this check; it removes the error, not the problem."
38
+
18
39
  type artifactKind string
19
40
 
20
41
  const (
@@ -87,6 +108,45 @@ type referencePolicy struct {
87
108
  // SingleEvidencePerSymbol requires exactly one distinct selected unit from
88
109
  // every selected semantic claim host, including the hosts carrying no tag.
89
110
  SingleEvidencePerSymbol bool
111
+ // RequireReview demands that every acknowledgement of this population carry
112
+ // an `@evidenceReview` whose fingerprint matches the cited scope's current
113
+ // content, so a review expires when the thing it reviewed moves.
114
+ RequireReview bool
115
+ }
116
+
117
+ // evidenceReview is one verification statement bound to its host and target.
118
+ //
119
+ // It is a sibling of evidenceDeclaration rather than a variant of it. Nothing
120
+ // that counts acknowledgements may reach a review, so the two never share a
121
+ // slice, a map, or a type. Target is the token as written and is compared
122
+ // against a declaration's target rather than resolved again: the citation and
123
+ // its review spell one address, and resolving twice would let a review answer a
124
+ // scope its citation does not name.
125
+ type evidenceReview struct {
126
+ // HostID is the source-position identity, kept only so a carrier with no
127
+ // semantic identity can still be matched. It must not be the primary key: a
128
+ // merged identity's halves sit at different positions and therefore carry
129
+ // different HostIDs, so matching on it alone refuses a review written on
130
+ // `namespace I` for a citation on `interface I` — placement the graph
131
+ // elsewhere calls not worth a diagnostic, and which `evidence/review` accepts.
132
+ HostID string
133
+ // SemanticHostIDs are the selected graph identities this review is written on,
134
+ // which is what a citation is matched by.
135
+ SemanticHostIDs []string
136
+ // Reviews names which acknowledgement this review answers for. It is part of
137
+ // the match, never inferred: verifying a citation and verifying an exclusion
138
+ // are opposite questions, so a review of one must not discharge the other.
139
+ Reviews tagKind
140
+ Type artifactKind
141
+ Target string
142
+ Fingerprint string
143
+ Description string
144
+ Path string
145
+ Line int
146
+ }
147
+
148
+ func (review *evidenceReview) location() string {
149
+ return review.Path + ":" + decimal(review.Line)
90
150
  }
91
151
 
92
152
  // entrySelected reports whether this reference materializes by traversal.
@@ -152,6 +212,56 @@ type evidenceUnit struct {
152
212
  // never selected and never hosts a declaration; it is retained only so a
153
213
  // citation of it can be told why the target it names is not there.
154
214
  Hidden string
215
+ // ValueSpace marks a unit reached only through a value: a function, a
216
+ // variable, a class member, or a member of an interface merged with a class.
217
+ //
218
+ // A type-only export exposes no value, so it exposes none of these either.
219
+ // The collector answers that for an export written in the declaring file,
220
+ // where it knows the declaration kind in hand. A re-export naming another
221
+ // module arrives at traversal time with no such context, which is what this
222
+ // field supplies: the same question, asked once at materialization and
223
+ // answered wherever reachability arrives through a type-only edge.
224
+ //
225
+ // It is read together with TypeSpace rather than alone, because a merged
226
+ // identity is written by more than one collector and a plain assignment would
227
+ // let source order decide.
228
+ ValueSpace bool
229
+ // TypeSpace marks a unit some declaration of which is reached without a
230
+ // value, and it wins over ValueSpace.
231
+ //
232
+ // One identity can be spelled by two collectors: `interface Order { member }`
233
+ // beside `namespace Order { export const member }` is one `property` unit
234
+ // written once by the member collector and once by the variable one. A
235
+ // type-only export exposes the interface half, so the unit survives, and the
236
+ // answer must not depend on which half the author wrote first.
237
+ TypeSpace bool
238
+ // Digest is this unit's content, hashed after normalization and with every
239
+ // position a tag can live in removed.
240
+ //
241
+ // How much "its content" is depends on the artifact, and the difference is
242
+ // load-bearing rather than incidental. A Markdown heading owns the lines
243
+ // between it and the next heading, so a subsection's body belongs to the
244
+ // subsection. A TypeScript unit owns its whole declaration text, which
245
+ // **contains** every nested member's text, because that is what a declaration
246
+ // is: there is no reading of `interface ISale` that excludes the members it
247
+ // declares, callables included.
248
+ //
249
+ // So a nested change moves both its own unit's digest and every enclosing
250
+ // unit's. `scopeIndex` composes ancestors and descendants anyway, so detection
251
+ // is unaffected, but nothing here may assume a unit's digest is independent of
252
+ // its subtree. A feature built on that assumption was reverted once already.
253
+ //
254
+ // Removing the documentation comment is what stops the review from
255
+ // invalidating itself: writing an `@evidenceReview` inside a unit that is
256
+ // itself cited would otherwise change the digest that the review's own
257
+ // fingerprint is checked against, and the repair would never terminate.
258
+ //
259
+ // Empty when the bridge that read this artifact reported no content for the
260
+ // unit, which is a loader gap rather than a configuration one: every
261
+ // reference kind may require a review, and every bridge digests what it
262
+ // parsed. A consumer of an empty digest reports nothing rather than
263
+ // comparing against nothing.
264
+ Digest string
155
265
  }
156
266
 
157
267
  func (unit *evidenceUnit) location() string {
@@ -204,7 +314,18 @@ type artifactInventory struct {
204
314
  Type artifactKind
205
315
  Units []*evidenceUnit
206
316
  Declarations []*evidenceDeclaration
207
- Problems []inventoryProblem
317
+ // Reviews are the verification statements this artifact carries, kept apart
318
+ // from Declarations so no consumer counting acknowledgements can reach one.
319
+ Reviews []*evidenceReview
320
+ Problems []inventoryProblem
321
+ // Unreadable lists the tags this artifact carries in a position nothing can
322
+ // read, already worded as diagnostics.
323
+ //
324
+ // They are kept apart from Problems because they are not a health question.
325
+ // The file loaded and its units are complete, so treating one as a failed
326
+ // population would suppress the obligations the author still owes while
327
+ // telling them only about a comment.
328
+ Unreadable []string
208
329
  // LoadFailed distinguishes an unreadable or rejected artifact from a
209
330
  // healthy artifact that legitimately materializes no selected units.
210
331
  // Coverage is a completeness claim, so a failed inventory cannot be used
@@ -232,6 +353,18 @@ type artifactInventory struct {
232
353
  // physical JSDoc declaration to semantic claim-host identities, then releases
233
354
  // it; callers with no such use leave it nil.
234
355
  UnitNodes map[string][]*shimast.Node
356
+ // UnitContent maps a unit ID to the nodes whose text is that identity's
357
+ // content, and is a subset of UnitNodes.
358
+ //
359
+ // Belonging to an identity and being its content are different questions,
360
+ // and a variable is where they part. TypeScript attaches a variable's
361
+ // leading documentation to the statement wrapper, so the wrapper is a
362
+ // position this identity owns — but the wrapper is also where its siblings
363
+ // are declared, and their text is not this identity's content. Answering the
364
+ // second question with the first makes one declarator's edit move another
365
+ // declarator's fingerprint. Each declaration site states which of its nodes
366
+ // is which; nothing here is derived from spans.
367
+ UnitContent map[string][]*shimast.Node
235
368
  }
236
369
 
237
370
  func (inventory *artifactInventory) recordUnitNode(id string, node *shimast.Node) {
@@ -241,6 +374,23 @@ func (inventory *artifactInventory) recordUnitNode(id string, node *shimast.Node
241
374
  inventory.UnitNodes[id] = append(inventory.UnitNodes[id], node)
242
375
  }
243
376
 
377
+ // recordUnitContent records a node as this identity's content, and as a
378
+ // position it owns.
379
+ //
380
+ // Content is recorded through the position index rather than beside it, so the
381
+ // subset the field documents holds by construction instead of by two callers
382
+ // agreeing.
383
+ func (inventory *artifactInventory) recordUnitContent(id string, node *shimast.Node) {
384
+ if inventory == nil || node == nil {
385
+ return
386
+ }
387
+ inventory.recordUnitNode(id, node)
388
+ if inventory.UnitNodes == nil || inventory.UnitContent == nil {
389
+ return
390
+ }
391
+ inventory.UnitContent[id] = append(inventory.UnitContent[id], node)
392
+ }
393
+
244
394
  type inventoryProblem struct {
245
395
  Symbol string
246
396
  Message string
@@ -251,6 +401,7 @@ type claimState struct {
251
401
  Paths []string
252
402
  Hosts []*evidenceUnit
253
403
  Declarations []*evidenceDeclaration
404
+ Reviews []*evidenceReview
254
405
  References []referenceState
255
406
  Healthy bool
256
407
  // OutsideCarrier names declarations this claim selected from a file its
@@ -264,6 +415,12 @@ type referenceState struct {
264
415
  Paths []string
265
416
  Units []*evidenceUnit
266
417
  Scopes []*evidenceUnit
418
+ // Population is every unit this reference's files materialized, selected or
419
+ // not. Units and Scopes are both narrowed by the `symbol` selector, so
420
+ // neither can answer what a cited scope structurally contains, and a review
421
+ // fingerprint has to be a property of the cited address rather than of the
422
+ // reference that asked for it.
423
+ Population []*evidenceUnit
267
424
  // Published names the module-and-address pairs a citation may use, for a
268
425
  // population selected by walking module exports. Left empty when the
269
426
  // population's addresses belong to the files that declare them.
@@ -294,3 +451,24 @@ func decimal(value int) string {
294
451
  }
295
452
  return string(digits)
296
453
  }
454
+
455
+ // markSpace records which space this declaration of the unit is reached
456
+ // through, letting type-space win.
457
+ //
458
+ // A plain assignment made the answer depend on which half of a merged identity
459
+ // the author wrote first, in the one shape where two collectors write one unit:
460
+ // an interface member beside a namespace member of the same name. The
461
+ // suppression it feeds is a silent one, so the divergence was a build going
462
+ // green or red on declaration order with no message either way.
463
+ //
464
+ // Every site goes through here, including the ones that can only ever be value
465
+ // space. A site that assigns the field directly is correct until the day its
466
+ // address collides with a type-space one, and that is exactly the day nobody is
467
+ // looking at it.
468
+ func (unit *evidenceUnit) markSpace(valueSpace bool) {
469
+ if valueSpace {
470
+ unit.ValueSpace = true
471
+ return
472
+ }
473
+ unit.TypeSpace = true
474
+ }
package/native/prisma.go CHANGED
@@ -94,15 +94,24 @@ type prismaSetProblem struct {
94
94
  }
95
95
 
96
96
  type prismaModel struct {
97
- Name string `json:"name"`
98
- Documentation string `json:"documentation"`
99
- Fields []prismaField `json:"fields"`
97
+ Name string `json:"name"`
98
+ Documentation string `json:"documentation"`
99
+ // Digest is the model's own declaration, hashed by the bridge that parsed it,
100
+ // with its documentation and its fields left out. A field is a unit of its
101
+ // own and the scope composes them, so folding them in here would make one
102
+ // field's edit expire a review of every sibling.
103
+ Digest string `json:"digest"`
104
+ Fields []prismaField `json:"fields"`
100
105
  }
101
106
 
102
107
  type prismaField struct {
103
108
  Name string `json:"name"`
104
109
  Symbol string `json:"symbol"`
105
110
  Documentation string `json:"documentation"`
111
+ // Digest is the field's own declaration, with its documentation left out.
112
+ // Its type, its attributes, and their arguments reach no other part of this
113
+ // process, so this is the only place a change to them is visible.
114
+ Digest string `json:"digest"`
106
115
  }
107
116
 
108
117
  // loadPrismaInventories materializes one inventory per configured schema file.
@@ -469,6 +478,7 @@ func prismaModelUnits(model prismaModel) []*evidenceUnit {
469
478
  Symbol: "model",
470
479
  Readable: "Prisma model '" + model.Name + "'",
471
480
  Hidden: modelHidden,
481
+ Digest: model.Digest,
472
482
  }}
473
483
  seen := map[string]bool{}
474
484
  for _, field := range model.Fields {
@@ -493,6 +503,7 @@ func prismaModelUnits(model prismaModel) []*evidenceUnit {
493
503
  Symbol: field.Symbol,
494
504
  Readable: "Prisma " + field.Symbol + " '" + model.Name + "." + field.Name + "'",
495
505
  Hidden: fieldHidden,
506
+ Digest: field.Digest,
496
507
  })
497
508
  }
498
509
  return units
@@ -566,6 +577,29 @@ func prismaDeclarationsFromComments(
566
577
  inventory.Declarations = append(inventory.Declarations, declaration)
567
578
  }
568
579
  }
580
+ // An unattached run is the one position that accepts `@evidenceExclude`
581
+ // and never `@evidence`, and a lint-only `.schema` ledger is built out of
582
+ // exactly these. Its exclusions owe a review like any other
583
+ // acknowledgement, so the review has to be readable here too. Collecting
584
+ // it only where a declaration is attached would leave the ledger reporting
585
+ // every exclusion unreviewed forever, with the repair written in a comment
586
+ // nothing reads back — the same dead end the attached case just escaped.
587
+ for _, review := range parseReviews(run.Body) {
588
+ shared := &evidenceReview{
589
+ HostID: "prisma:" + run.Path + ":file",
590
+ SemanticHostIDs: []string{"prisma:" + run.Path + ":file"},
591
+ Reviews: review.Reviews,
592
+ Type: artifactPrisma,
593
+ Target: review.Target,
594
+ Fingerprint: review.Fingerprint,
595
+ Description: review.Description,
596
+ Path: run.Path,
597
+ Line: run.Line + review.LineOffset,
598
+ }
599
+ for _, inventory := range hosted {
600
+ inventory.Reviews = append(inventory.Reviews, shared)
601
+ }
602
+ }
569
603
  continue
570
604
  }
571
605
  if run.Key == "" {
@@ -622,6 +656,28 @@ func prismaDeclarationsFromComments(
622
656
  inventory.Declarations = append(inventory.Declarations, declaration)
623
657
  }
624
658
  }
659
+ // Reviews are read here for the same reason the citations are. Without
660
+ // this, a Prisma claim citing a reference that requires a review reports
661
+ // every citation unreviewed forever, and the repair the message names —
662
+ // write the tag in this documentation comment — has no effect, because
663
+ // nothing reads the tag back out. A diagnostic must never name a repair the
664
+ // author cannot perform.
665
+ for _, review := range parseReviews(run.Body) {
666
+ shared := &evidenceReview{
667
+ HostID: host.ID,
668
+ SemanticHostIDs: []string{host.ID},
669
+ Reviews: review.Reviews,
670
+ Type: artifactPrisma,
671
+ Target: review.Target,
672
+ Fingerprint: review.Fingerprint,
673
+ Description: review.Description,
674
+ Path: run.Path,
675
+ Line: run.Line + review.LineOffset,
676
+ }
677
+ for _, inventory := range hosted {
678
+ inventory.Reviews = append(inventory.Reviews, shared)
679
+ }
680
+ }
625
681
  }
626
682
  return problems
627
683
  }