@ttsc/evidence 0.31.5 → 0.32.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/README.md CHANGED
@@ -117,6 +117,19 @@ One claim: the components under `src` implement the docs, so every H2 and H3 und
117
117
 
118
118
  The configuration is written once. The tags are written forever: `@evidence` cites, `@evidenceReview` verifies, and `@evidenceExclude` declines. [The tag reference](https://ttsc.dev/docs/evidence/tags) has the full grammar.
119
119
 
120
+ A reason that needs paragraphs, a list or subheadings goes under an H2 to H4 heading, with the target on the heading and the explanation below it:
121
+
122
+ ```ts
123
+ /**
124
+ * ## @evidence docs/discount.md#coupon-stacking
125
+ *
126
+ * Renders the per-issuer limit this section defines.
127
+ *
128
+ * - Reads the limit from props.
129
+ * - Names the issuer that reached it.
130
+ */
131
+ ```
132
+
120
133
  A false tag removes the error, not the problem. `@evidenceReview` asks for a separate statement of what was verified, which a human then judges:
121
134
 
122
135
  ```ts
@@ -60,70 +60,177 @@ func parseCommentDeclarations(
60
60
  tagBoundaries bool,
61
61
  ) []parsedDeclaration {
62
62
  body, leadingLines, documented := documentationCommentBody(comment)
63
- jsdoc := documented || tagBoundaries
64
- type pendingDeclaration struct {
65
- tag tagKind
66
- body []string
67
- lineOffset int
68
- }
69
- var pending *pendingDeclaration
63
+ return parseRegionDeclarations(
64
+ documentationRegions(body, false),
65
+ documented || tagBoundaries,
66
+ leadingLines,
67
+ )
68
+ }
69
+
70
+ // parseRegionDeclarations reads the citations and exclusions of documentation
71
+ // lines whose examples are already classified.
72
+ //
73
+ // It owns the annotation grammar independently of where the lines came from, so
74
+ // every host that can spell an annotation agrees on where one begins, what it
75
+ // says and where it ends. tagBoundaries has the meaning documented on
76
+ // parseCommentDeclarations.
77
+ func parseRegionDeclarations(
78
+ regions []documentationRegionLine,
79
+ tagBoundaries bool,
80
+ leadingLines int,
81
+ ) []parsedDeclaration {
82
+ var pending *pendingAnnotation
70
83
  parsed := []parsedDeclaration{}
71
84
  flush := func() {
72
85
  if pending == nil {
73
86
  return
74
87
  }
75
- target, reason := splitDeclarationBody(strings.Join(pending.body, "\n"))
88
+ target, reason := pending.split()
76
89
  parsed = append(parsed, parsedDeclaration{
77
- Tag: pending.tag,
90
+ Tag: pending.opener.tag,
78
91
  Target: target,
79
92
  Reason: reason,
80
93
  LineOffset: leadingLines + pending.lineOffset,
81
94
  })
82
95
  pending = nil
83
96
  }
84
- for index, region := range documentationRegions(body, false) {
97
+ for index, region := range regions {
85
98
  if region.fence {
86
- flush()
99
+ // An example never supplies prose. A line-form annotation ends at it; a
100
+ // heading section resumes after it, because headings rather than examples
101
+ // decide where a section ends.
102
+ if !pending.section() {
103
+ flush()
104
+ }
87
105
  continue
88
106
  }
89
- line := region.text
90
- tag, body, found := declarationLine(line)
91
- if found {
107
+ if opener, opened := region.opener(); opened {
92
108
  flush()
93
- pending = &pendingDeclaration{
94
- tag: tag,
95
- body: []string{body},
96
- lineOffset: index,
109
+ // A review closes the declaration above it in every host, and it does so
110
+ // regardless of tagBoundaries. That flag answers whether *another tool's*
111
+ // `@tag` is a boundary, which is a property of the host's comment grammar:
112
+ // an HTML comment has no field syntax, so `@architecture approved this` is
113
+ // ordinary prose belonging to the reason above it, and a test pins that.
114
+ //
115
+ // A review is not another tool's tag. It belongs to this grammar, so it is
116
+ // always a boundary. Without this, an `@evidenceReview` written under an
117
+ // `@evidence` inside one Markdown comment was swallowed into that citation's
118
+ // reason: the review vanished and the reason grew a sentence its author
119
+ // addressed to a different question.
120
+ if !opener.review {
121
+ pending = &pendingAnnotation{opener: opener, lineOffset: index}
97
122
  }
98
123
  continue
99
124
  }
100
- // A review closes the declaration above it in every host, and it does so
101
- // regardless of tagBoundaries. That flag answers whether *another tool's*
102
- // `@tag` is a boundary, which is a property of the host's comment grammar:
103
- // an HTML comment has no field syntax, so `@architecture approved this` is
104
- // ordinary prose belonging to the reason above it, and a test pins that.
105
- //
106
- // A review is not another tool's tag. It belongs to this grammar, so it is
107
- // always a boundary. Without this, an `@evidenceReview` written under an
108
- // `@evidence` inside one Markdown comment was swallowed into that citation's
109
- // reason: the review vanished and the reason grew a sentence its author
110
- // addressed to a different question.
111
- if _, _, opened := reviewLine(line); opened {
125
+ if pending.closedBy(region) {
112
126
  flush()
113
127
  continue
114
128
  }
115
- if jsdoc && strings.HasPrefix(line, "@") {
129
+ if tagBoundaries && region.opensTagBoundary() {
116
130
  flush()
117
131
  continue
118
132
  }
119
133
  if pending != nil {
120
- pending.body = append(pending.body, line)
134
+ pending.lines = append(pending.lines, region.text)
121
135
  }
122
136
  }
123
137
  flush()
124
138
  return parsed
125
139
  }
126
140
 
141
+ // annotationOpener is the start of one annotation as a documentation line
142
+ // spells it.
143
+ //
144
+ // The line form opens with the tag itself. The heading form is an H2, H3 or H4
145
+ // heading whose title opens with the tag, and it exists for explanations long
146
+ // enough to need paragraphs, lists and subheadings of their own.
147
+ type annotationOpener struct {
148
+ // review reports a verification statement rather than an acknowledgement.
149
+ review bool
150
+
151
+ // tag is the acknowledgement kind, or for a review the kind it answers for.
152
+ tag tagKind
153
+
154
+ // body is what follows the tag on the opening line: the target, and any
155
+ // reason or review remainder written beside it.
156
+ body string
157
+
158
+ // heading is the depth of a heading-form opener, or zero for the line form.
159
+ heading int
160
+ }
161
+
162
+ // openAnnotation reads the annotation one documentation line opens, if any.
163
+ //
164
+ // A heading of another depth stays an ordinary heading, and no heading opens
165
+ // the line form because its first character is `#`. The five tags share one
166
+ // spelling in both forms, so the two forms cannot disagree about which tag a
167
+ // line carries: the title of a heading is read by the same matchers as a line.
168
+ func openAnnotation(text string, heading int, title string) (annotationOpener, bool) {
169
+ if heading != 0 {
170
+ if heading < 2 || heading > 4 {
171
+ return annotationOpener{}, false
172
+ }
173
+ text = title
174
+ }
175
+ if tag, body, found := declarationLine(text); found {
176
+ return annotationOpener{tag: tag, body: body, heading: heading}, true
177
+ }
178
+ if reviews, remainder, opened := reviewLine(text); opened {
179
+ return annotationOpener{review: true, tag: reviews, body: remainder, heading: heading}, true
180
+ }
181
+ return annotationOpener{}, false
182
+ }
183
+
184
+ // opensAcknowledgement reports whether a documentation line opens a citation
185
+ // or an exclusion in either form, for a caller that reports a tag it cannot
186
+ // read and so needs only to know that one is there.
187
+ func opensAcknowledgement(text string, heading int, title string) bool {
188
+ opener, opened := openAnnotation(text, heading, title)
189
+ return opened && !opener.review
190
+ }
191
+
192
+ // pendingAnnotation accumulates one annotation until a boundary ends it.
193
+ type pendingAnnotation struct {
194
+ opener annotationOpener
195
+
196
+ // lines are the documentation lines after the opener that continue it.
197
+ lines []string
198
+
199
+ // lineOffset is the opener's line within the documentation body.
200
+ lineOffset int
201
+ }
202
+
203
+ // section reports whether this is a heading-form annotation. A nil receiver is
204
+ // no annotation at all.
205
+ func (pending *pendingAnnotation) section() bool {
206
+ return pending != nil && pending.opener.heading != 0
207
+ }
208
+
209
+ // closedBy reports whether a heading ends this annotation's explanation.
210
+ //
211
+ // Only a heading section has that structure. A heading of its own depth or
212
+ // shallower is the next part of the surrounding document; a deeper heading
213
+ // divides the explanation itself and stays inside it. A line-form annotation
214
+ // keeps the continuation it always had, which a heading does not end.
215
+ func (pending *pendingAnnotation) closedBy(region documentationRegionLine) bool {
216
+ return pending.section() && region.heading != 0 && region.heading <= pending.opener.heading
217
+ }
218
+
219
+ // split returns the annotation's target and the text after it.
220
+ //
221
+ // A line-form target may be written on the line after its tag, so its lines
222
+ // are split as one body, as they always were. A heading section's target must
223
+ // be on the heading: the heading body is split alone, which keeps the
224
+ // explanation from ever becoming a target and keeps an unterminated link on the
225
+ // heading from borrowing a closing brace from the prose under it.
226
+ func (pending *pendingAnnotation) split() (string, string) {
227
+ if !pending.section() {
228
+ return splitDeclarationBody(strings.Join(append([]string{pending.opener.body}, pending.lines...), "\n"))
229
+ }
230
+ target, remainder := splitDeclarationBody(pending.opener.body)
231
+ return target, strings.TrimSpace(strings.Join(append([]string{remainder}, pending.lines...), "\n"))
232
+ }
233
+
127
234
  func declarationLine(line string) (tagKind, string, bool) {
128
235
  for _, candidate := range []struct {
129
236
  marker string
@@ -314,43 +421,55 @@ func (review parsedReview) marker() string {
314
421
  // block with `@param` or a following `@evidence` swallows neither.
315
422
  func parseReviews(comment string) []parsedReview {
316
423
  comment, leadingLines, _ := documentationCommentBody(comment)
424
+ return parseRegionReviews(documentationRegions(comment, true), leadingLines)
425
+ }
426
+
427
+ // parseRegionReviews reads the reviews of documentation lines whose examples
428
+ // are already classified, under the grammar parseRegionDeclarations applies to
429
+ // acknowledgements. A heading-form review may carry its fingerprint on the
430
+ // heading or open its description with it.
431
+ func parseRegionReviews(regions []documentationRegionLine, leadingLines int) []parsedReview {
317
432
  reviews := []parsedReview{}
318
- var pending *parsedReview
319
- var body []string
433
+ var pending *pendingAnnotation
320
434
  flush := func() {
321
435
  if pending == nil {
322
436
  return
323
437
  }
324
- target, remainder := splitDeclarationBody(strings.Join(body, "\n"))
438
+ target, remainder := pending.split()
325
439
  fingerprint, description := splitReviewFingerprint(remainder)
326
- pending.Target = target
327
- pending.Fingerprint = fingerprint
328
- pending.Description = description
329
- reviews = append(reviews, *pending)
440
+ reviews = append(reviews, parsedReview{
441
+ Reviews: pending.opener.tag,
442
+ Target: target,
443
+ Fingerprint: fingerprint,
444
+ Description: description,
445
+ LineOffset: leadingLines + pending.lineOffset,
446
+ })
330
447
  pending = nil
331
- body = nil
332
448
  }
333
- for index, region := range documentationRegions(comment, true) {
449
+ for index, region := range regions {
334
450
  if region.fence {
335
- flush()
451
+ if !pending.section() {
452
+ flush()
453
+ }
336
454
  continue
337
455
  }
338
- line := region.text
339
- if reviews, remainder, opened := reviewLine(line); opened {
456
+ if opener, opened := region.opener(); opened {
340
457
  flush()
341
- pending = &parsedReview{
342
- Reviews: reviews,
343
- LineOffset: leadingLines + index,
458
+ if opener.review {
459
+ pending = &pendingAnnotation{opener: opener, lineOffset: index}
344
460
  }
345
- body = []string{remainder}
346
461
  continue
347
462
  }
348
- if strings.HasPrefix(line, "@") {
463
+ if pending.closedBy(region) {
464
+ flush()
465
+ continue
466
+ }
467
+ if region.opensTagBoundary() {
349
468
  flush()
350
469
  continue
351
470
  }
352
471
  if pending != nil {
353
- body = append(body, line)
472
+ pending.lines = append(pending.lines, region.text)
354
473
  }
355
474
  }
356
475
  flush()
@@ -13,6 +13,52 @@ type documentationRegionLine struct {
13
13
 
14
14
  // fence ends pending prose and suppresses tags throughout the example.
15
15
  fence bool
16
+
17
+ // heading is the ATX depth when the line is a heading, or zero. A heading
18
+ // annotation's explanation ends at a heading of its own depth or shallower.
19
+ heading int
20
+
21
+ // title is the heading text without closing hashes or an explicit anchor,
22
+ // which are heading syntax and never part of an annotation's target or reason.
23
+ title string
24
+
25
+ // explanatory marks prose that can only continue an explanation: it opens no
26
+ // one-line annotation and ends none. Visible Markdown is read this way,
27
+ // because rendered prose carries annotations only under headings.
28
+ explanatory bool
29
+ }
30
+
31
+ // opener reads the annotation this line opens, if any. An explanatory line
32
+ // opens one only as a heading.
33
+ func (region documentationRegionLine) opener() (annotationOpener, bool) {
34
+ if region.explanatory && region.heading == 0 {
35
+ return annotationOpener{}, false
36
+ }
37
+ return openAnnotation(region.text, region.heading, region.title)
38
+ }
39
+
40
+ // opensTagBoundary reports whether the line opens some `@tag`, which ends a
41
+ // pending explanation in hosts whose grammar gives tags that meaning.
42
+ func (region documentationRegionLine) opensTagBoundary() bool {
43
+ return !region.explanatory && strings.HasPrefix(region.text, "@")
44
+ }
45
+
46
+ // documentationHeading reads an ATX heading from one line of documentation
47
+ // prose or of a Markdown file's heading view, either of which may keep up to
48
+ // three columns of margin.
49
+ //
50
+ // It uses the Markdown scanner's own heading grammar, so a heading inside a
51
+ // documentation comment and a heading in a Markdown file agree on depth,
52
+ // required spacing and closing hashes. The explicit `{#anchor}` suffix is
53
+ // removed because it names the heading for Markdown addressing, which an
54
+ // annotation's target or reason must never absorb.
55
+ func documentationHeading(text string) (int, string) {
56
+ level, title, ok := markdownHeading(text)
57
+ if !ok {
58
+ return 0, ""
59
+ }
60
+ title, _ = markdownHeadingIdentity(title)
61
+ return level, title
16
62
  }
17
63
 
18
64
  // documentationCommentBody removes only a real native wrapper. Mapped Prisma
@@ -139,6 +185,11 @@ func documentationRegions(body string, slashDecoration bool) []documentationRegi
139
185
  at++
140
186
  }
141
187
  result[index].text = strings.TrimSpace(string(masked))
188
+ // A heading allows the same three columns of margin a fence does; four or
189
+ // more make the line indented code, which opens and ends nothing.
190
+ if relative >= 0 && relative <= 3 {
191
+ result[index].heading, result[index].title = documentationHeading(result[index].text)
192
+ }
142
193
  offset += len(line) + 1
143
194
  }
144
195
  return result
@@ -665,13 +665,55 @@ func scanMarkdownInventory(
665
665
  currentHost := "file"
666
666
  currentHostID := fileUnitID
667
667
  lexical, commentSpans := scanMarkdownRegions(content, lines)
668
+ sections := markdownAnnotationSections(lexical)
669
+ sectionAt := make([]int, len(lines))
670
+ for index := range sectionAt {
671
+ sectionAt[index] = -1
672
+ }
673
+ for position, section := range sections {
674
+ for index := section.start; index < section.end; index++ {
675
+ sectionAt[index] = position
676
+ }
677
+ }
678
+ // The latest outline heading at each depth, so an annotation section can
679
+ // name the heading it is nested in. Annotation headings never enter it: they
680
+ // are metadata about a section, not sections of their own.
681
+ outlineHosts := [7]markdownHost{}
682
+ sectionOwners := make([]markdownHost, len(sections))
668
683
  headingUnitIDs := [5]string{}
669
684
  for index := range lines {
685
+ if position := sectionAt[index]; position >= 0 {
686
+ section := sections[position]
687
+ if index == section.start {
688
+ // An annotation answers for the section it is nested in: the nearest
689
+ // heading shallower than its own, or the file. A heading of its own
690
+ // depth is a sibling it does not belong to.
691
+ sectionOwners[position] = markdownHost{symbol: "file", id: fileUnitID}
692
+ for depth := section.level - 1; depth >= 1; depth-- {
693
+ if outlineHosts[depth].symbol != "" {
694
+ sectionOwners[position] = outlineHosts[depth]
695
+ break
696
+ }
697
+ }
698
+ }
699
+ // Every line of the section, including a comment written in it, belongs
700
+ // to that owner, and none of them is any unit's content: an annotation is
701
+ // metadata exactly as an HTML comment is, so writing or editing one
702
+ // expires no review.
703
+ hostAtLine[index] = sectionOwners[position].symbol
704
+ hostIDAtLine[index] = sectionOwners[position].id
705
+ digestHostIDAtLine[index] = ""
706
+ continue
707
+ }
670
708
  visible := lexical[index].Heading
671
709
  level, title, ok := markdownHeading(visible)
672
710
  if ok {
673
711
  currentHost = "h" + decimal(level)
674
712
  currentHostID = "markdown:" + address.Key + ":" + currentHost + ":" + decimal(index+1)
713
+ outlineHosts[level] = markdownHost{symbol: currentHost, id: currentHostID}
714
+ for deeper := level + 1; deeper < len(outlineHosts); deeper++ {
715
+ outlineHosts[deeper] = markdownHost{}
716
+ }
675
717
  if level <= 4 {
676
718
  for descendantLevel := level; descendantLevel <= 4; descendantLevel++ {
677
719
  headingUnitIDs[descendantLevel] = ""
@@ -750,7 +792,7 @@ func scanMarkdownInventory(
750
792
  digestHostIDAtLine[index] = currentDigestHostID
751
793
  }
752
794
 
753
- reportUnreadableMarkdownTags(inventory, address.Display, lexical)
795
+ reportUnreadableMarkdownTags(inventory, address.Display, lexical, sections)
754
796
 
755
797
  sequence := 0
756
798
  for _, match := range commentSpans {
@@ -795,10 +837,50 @@ func scanMarkdownInventory(
795
837
  })
796
838
  }
797
839
  }
840
+ for position, section := range sections {
841
+ owner := sectionOwners[position]
842
+ regions := markdownSectionRegions(lines, lexical, section)
843
+ for _, parsed := range parseRegionDeclarations(regions, false, 0) {
844
+ sequence++
845
+ line := section.start + 1 + parsed.LineOffset
846
+ inventory.Declarations = append(inventory.Declarations, &evidenceDeclaration{
847
+ ID: "markdown:" + address.Key + ":" + decimal(line) + ":" + decimal(sequence),
848
+ HostID: owner.id,
849
+ SemanticHostIDs: []string{owner.id},
850
+ Type: artifactMarkdown,
851
+ Tag: parsed.Tag,
852
+ Target: parsed.Target,
853
+ Reason: parsed.Reason,
854
+ Hosts: symbolSet{owner.symbol: true},
855
+ Path: address.Display,
856
+ Line: line,
857
+ Sequence: sequence,
858
+ })
859
+ }
860
+ for _, review := range parseRegionReviews(regions, 0) {
861
+ inventory.Reviews = append(inventory.Reviews, &evidenceReview{
862
+ SemanticHostIDs: []string{owner.id},
863
+ Reviews: review.Reviews,
864
+ Type: artifactMarkdown,
865
+ Target: review.Target,
866
+ Fingerprint: review.Fingerprint,
867
+ Description: review.Description,
868
+ Path: address.Display,
869
+ Line: section.start + 1 + review.LineOffset,
870
+ })
871
+ }
872
+ }
798
873
  assignMarkdownDigests(inventory, content, lines, digestHostIDAtLine, commentSpans)
799
874
  return inventory, problems
800
875
  }
801
876
 
877
+ // markdownHost names the host a Markdown line belongs to: the symbol a claim
878
+ // selects it by and the identity its declarations record.
879
+ type markdownHost struct {
880
+ symbol string
881
+ id string
882
+ }
883
+
802
884
  // assignMarkdownDigests gives every unit the text it alone owns.
803
885
  //
804
886
  // A heading owns its own line and the body under it up to the next heading, and a
@@ -978,6 +1060,132 @@ func selectedByMarkdownPopulation(
978
1060
  return false
979
1061
  }
980
1062
 
1063
+ // markdownAnnotationSection is one visible heading annotation: the zero-based
1064
+ // line of its opening heading, the line after its explanation, and the
1065
+ // opening heading's depth.
1066
+ type markdownAnnotationSection struct {
1067
+ start int
1068
+ end int
1069
+ level int
1070
+ }
1071
+
1072
+ // markdownAnnotationSections finds every visible H2-H4 annotation heading and
1073
+ // the explanation under it.
1074
+ //
1075
+ // The headings are the outline's own: the lexical heading view decides them,
1076
+ // so a heading inside code, a rendered example or a comment opens nothing, and
1077
+ // one inside a blockquote or a list is not a heading of the document at all. A
1078
+ // section ends at the next heading of its depth or shallower, which is the next
1079
+ // part of the document, or at the next annotation heading, which starts its
1080
+ // own. A deeper ordinary heading divides the explanation and stays inside it.
1081
+ func markdownAnnotationSections(lexical []markdownLexicalLine) []markdownAnnotationSection {
1082
+ sections := []markdownAnnotationSection{}
1083
+ for index := 0; index < len(lexical); {
1084
+ level, title := documentationHeading(lexical[index].Heading)
1085
+ if _, opened := openAnnotation(lexical[index].Heading, level, title); level == 0 || !opened {
1086
+ index++
1087
+ continue
1088
+ }
1089
+ end := index + 1
1090
+ for ; end < len(lexical); end++ {
1091
+ next, nextTitle := documentationHeading(lexical[end].Heading)
1092
+ if next == 0 {
1093
+ continue
1094
+ }
1095
+ if _, opened := openAnnotation(lexical[end].Heading, next, nextTitle); opened || next <= level {
1096
+ break
1097
+ }
1098
+ }
1099
+ sections = append(sections, markdownAnnotationSection{start: index, end: end, level: level})
1100
+ index = end
1101
+ }
1102
+ return sections
1103
+ }
1104
+
1105
+ // markdownSectionRegions presents one visible annotation section to the shared
1106
+ // annotation grammar.
1107
+ //
1108
+ // Headings keep their outline reading, so the grammar sees the same depths the
1109
+ // outline does. Every other line is explanatory prose: it can explain, but in
1110
+ // rendered Markdown only a heading carries an annotation, so it opens and ends
1111
+ // none.
1112
+ func markdownSectionRegions(
1113
+ lines []string,
1114
+ lexical []markdownLexicalLine,
1115
+ section markdownAnnotationSection,
1116
+ ) []documentationRegionLine {
1117
+ regions := make([]documentationRegionLine, 0, section.end-section.start)
1118
+ for index := section.start; index < section.end; index++ {
1119
+ if level, title := documentationHeading(lexical[index].Heading); level != 0 {
1120
+ regions = append(regions, documentationRegionLine{
1121
+ text: strings.TrimSpace(lexical[index].Heading),
1122
+ heading: level,
1123
+ title: title,
1124
+ explanatory: true,
1125
+ })
1126
+ continue
1127
+ }
1128
+ regions = append(regions, documentationRegionLine{
1129
+ text: markdownReadableProse(strings.TrimSuffix(lines[index], "\r"), lexical[index]),
1130
+ explanatory: true,
1131
+ })
1132
+ }
1133
+ return regions
1134
+ }
1135
+
1136
+ // markdownReadableProse returns what a reader of the rendered document reads
1137
+ // as prose on one line, or nothing when the line is no prose.
1138
+ //
1139
+ // Whether a line explains is decided by the prose view: a line left empty once
1140
+ // code, rendered examples, comments and escapes are removed is an example or
1141
+ // metadata, so an example cannot supply a missing reason. Two visible lines are
1142
+ // no prose either. A closing fence is the code block's syntax although the
1143
+ // parser leaves it outside the block, and a line opening a tag is an
1144
+ // annotation nothing reads, which is reported on its own rather than becoming
1145
+ // the explanation of another.
1146
+ //
1147
+ // What an explaining line says is read from the heading view instead, which
1148
+ // removes comments but keeps inline code and the other inline text a reader
1149
+ // reads, so a review description shown in a diagnostic keeps its words.
1150
+ func markdownReadableProse(line string, lexical markdownLexicalLine) string {
1151
+ if _, opened := markdownProseTag(markdownLineContent(lexical.Prose)); opened {
1152
+ return ""
1153
+ }
1154
+ prose := markdownKeptText(line, lexical.Prose)
1155
+ if prose == "" {
1156
+ return ""
1157
+ }
1158
+ if _, _, _, fence := markdownFence(markdownLineContent(prose)); fence {
1159
+ return ""
1160
+ }
1161
+ return markdownKeptText(line, lexical.Heading)
1162
+ }
1163
+
1164
+ // markdownKeptText keeps the bytes of a line that a lexical view left
1165
+ // unmasked. A view never moves a byte, so comparing positions is exact, and a
1166
+ // sentinel a view writes over masked bytes differs from the source it hides.
1167
+ func markdownKeptText(line string, view string) string {
1168
+ kept := []byte(line)
1169
+ for index := range kept {
1170
+ if index >= len(view) || view[index] != line[index] {
1171
+ kept[index] = ' '
1172
+ }
1173
+ }
1174
+ return strings.TrimSpace(string(kept))
1175
+ }
1176
+
1177
+ // markdownProseTag returns the tag a line of visible prose opens, spelled the
1178
+ // way an unreadable-tag diagnostic names it.
1179
+ func markdownProseTag(content string) (string, bool) {
1180
+ if tag, _, found := declarationLine(content); found {
1181
+ return "@" + string(tag), true
1182
+ }
1183
+ if reviews, _, opened := reviewLine(content); opened {
1184
+ return reviewMarkerFor(reviews), true
1185
+ }
1186
+ return "", false
1187
+ }
1188
+
981
1189
  func markdownFence(line string) (rune, int, string, bool) {
982
1190
  indent := 0
983
1191
  for indent < len(line) && line[indent] == ' ' {
@@ -1060,12 +1268,17 @@ func markdownSlug(title string) string {
1060
1268
  // reportUnreadableMarkdownTags records every tag written where this artifact
1061
1269
  // kind cannot read one.
1062
1270
  //
1063
- // A Markdown declaration is read from an HTML comment, so the tag renders
1064
- // invisibly and the author sees the same source either way. Written as prose it
1065
- // reaches no host and used to be discarded without a word, leaving the coverage
1066
- // diagnostic that follows to name the reference and suggest writing the
1067
- // citation the author had already written. TypeScript answers this shape and
1068
- // the Prisma bridge answers its own; this is the kind that was left silent.
1271
+ // A Markdown declaration is read from an HTML comment or from an H2, H3 or H4
1272
+ // annotation heading of the document. Written as prose it reaches no host and
1273
+ // used to be discarded without a word, leaving the coverage diagnostic that
1274
+ // follows to name the reference and suggest writing the citation the author
1275
+ // had already written. TypeScript answers this shape and the Prisma bridge
1276
+ // answers its own; this is the kind that was left silent.
1277
+ //
1278
+ // A heading whose title opens a tag is the same mistake when it carries
1279
+ // nothing: an H1, H5 or H6, or a heading inside a blockquote or a list, which
1280
+ // is no heading of the document's outline. Every heading that does open an
1281
+ // annotation section is skipped, since the section reads it.
1069
1282
  //
1070
1283
  // A fenced block is an example rather than a citation and stays silent, which
1071
1284
  // is not a concession: this product's own documentation shows tags inside
@@ -1082,18 +1295,28 @@ func reportUnreadableMarkdownTags(
1082
1295
  inventory *artifactInventory,
1083
1296
  location string,
1084
1297
  lexical []markdownLexicalLine,
1298
+ sections []markdownAnnotationSection,
1085
1299
  ) {
1086
1300
  if inventory == nil {
1087
1301
  return
1088
1302
  }
1303
+ openers := make(map[int]bool, len(sections))
1304
+ for _, section := range sections {
1305
+ openers[section.start] = true
1306
+ }
1089
1307
  for index, line := range lexical {
1090
1308
  trimmed := markdownLineContent(line.Prose)
1091
- if tag, _, found := declarationLine(trimmed); found {
1092
- inventory.Unreadable = append(inventory.Unreadable, unreadableMarkdownProblem("@"+string(tag), location, index+1))
1309
+ if tag, found := markdownProseTag(trimmed); found {
1310
+ inventory.Unreadable = append(inventory.Unreadable, unreadableMarkdownProblem(tag, location, index+1, false))
1093
1311
  continue
1094
1312
  }
1095
- if reviews, _, opened := reviewLine(trimmed); opened {
1096
- inventory.Unreadable = append(inventory.Unreadable, unreadableMarkdownProblem(reviewMarkerFor(reviews), location, index+1))
1313
+ if openers[index] {
1314
+ continue
1315
+ }
1316
+ if level, title := documentationHeading(trimmed); level != 0 {
1317
+ if tag, found := markdownProseTag(title); found {
1318
+ inventory.Unreadable = append(inventory.Unreadable, unreadableMarkdownProblem(tag, location, index+1, true))
1319
+ }
1097
1320
  }
1098
1321
  }
1099
1322
  }
@@ -1145,11 +1368,19 @@ func markdownListMarker(content string) int {
1145
1368
  return digits + 2
1146
1369
  }
1147
1370
 
1148
- // unreadableMarkdownProblem names the position and the move that fixes it.
1149
- func unreadableMarkdownProblem(tag string, location string, line int) string {
1150
- return "Unreadable " + tag + " at " + location + ":" + decimal(line) +
1151
- ": a Markdown declaration is read from an HTML comment, and this line is prose, so nothing reads the tag." +
1152
- " Wrap it as '<!-- " + tag + " <target> " + unreadableMarkdownField(tag) + " -->'."
1371
+ // unreadableMarkdownProblem names the position and the moves that fix it.
1372
+ //
1373
+ // Both carriers are named because either repairs the line: a visible heading
1374
+ // keeps a long explanation readable, and a comment keeps a short one out of the
1375
+ // rendered page.
1376
+ func unreadableMarkdownProblem(tag string, location string, line int, heading bool) string {
1377
+ cause := "a Markdown declaration is read from an HTML comment or from an H2, H3 or H4 annotation heading, and this line is prose"
1378
+ if heading {
1379
+ cause = "a Markdown declaration is read from an HTML comment or from an H2, H3 or H4 heading of the document itself, and this heading is neither"
1380
+ }
1381
+ return "Unreadable " + tag + " at " + location + ":" + decimal(line) + ": " + cause + ", so nothing reads the tag." +
1382
+ " Write it as a '## " + tag + " <target>' heading with " + unreadableMarkdownField(tag) + " below it," +
1383
+ " or wrap it as '<!-- " + tag + " <target> " + unreadableMarkdownField(tag) + " -->'."
1153
1384
  }
1154
1385
 
1155
1386
  // unreadableMarkdownField names what follows a target for this tag.
package/native/prisma.go CHANGED
@@ -910,11 +910,10 @@ func prismaCommentCarriesTag(body string) bool {
910
910
  if region.fence {
911
911
  continue
912
912
  }
913
- trimmed := region.text
914
- if _, _, found := declarationLine(trimmed); found {
913
+ if opensAcknowledgement(region.text, region.heading, region.title) {
915
914
  return true
916
915
  }
917
- if prismaBuriedTag(trimmed) {
916
+ if prismaBuriedTag(region.text) {
918
917
  return true
919
918
  }
920
919
  }
@@ -936,11 +935,10 @@ func prismaBuriedTagLines(body string) []int {
936
935
  if region.fence {
937
936
  continue
938
937
  }
939
- trimmed := region.text
940
- if _, _, found := declarationLine(trimmed); found {
938
+ if opensAcknowledgement(region.text, region.heading, region.title) {
941
939
  continue
942
940
  }
943
- if prismaBuriedTag(trimmed) {
941
+ if prismaBuriedTag(region.text) {
944
942
  offsets = append(offsets, offset)
945
943
  }
946
944
  }
@@ -965,8 +963,8 @@ func prismaBuriedTag(trimmed string) bool {
965
963
  if stripped == trimmed {
966
964
  return false
967
965
  }
968
- _, _, found := declarationLine(stripped)
969
- return found
966
+ heading, title := documentationHeading(stripped)
967
+ return opensAcknowledgement(stripped, heading, title)
970
968
  }
971
969
 
972
970
  // prismaNormalizationFailure words a rejected schema identically whether the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/evidence",
3
- "version": "0.31.5",
3
+ "version": "0.32.1",
4
4
  "description": "Evidence-graph lint contributor for @ttsc/lint: declare provenance with a JSDoc @evidence tag and fail the build when it is missing or dangling.",
5
5
  "main": "lib/index.js",
6
6
  "exports": {
@@ -45,12 +45,12 @@
45
45
  "@ttsc/lint": ">=0.31.0"
46
46
  },
47
47
  "devDependencies": {
48
- "@wrtnlabs/evidence": "^0.3.1",
48
+ "@wrtnlabs/evidence": "^0.5.1",
49
49
  "@types/node": "^25.3.0",
50
50
  "rimraf": "^6.1.2",
51
51
  "typescript": "^7.0.2",
52
- "@ttsc/lint": "0.31.5",
53
- "ttsc": "0.31.5"
52
+ "ttsc": "0.32.1",
53
+ "@ttsc/lint": "0.32.1"
54
54
  },
55
55
  "dependencies": {
56
56
  "@prisma/prisma-schema-wasm": "7.10.0-2.d0368e25c88e68caa4d283b5f2d88d1b76ed9356",