@ttsc/evidence 0.30.4 → 0.31.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.
Files changed (66) hide show
  1. package/README.md +14 -6
  2. package/lib/index.d.ts +2 -3
  3. package/lib/index.js +2 -3
  4. package/lib/internal/canonicalDigest.js +12 -6
  5. package/lib/internal/loadPrismaModels.js +52 -7
  6. package/lib/internal/loadSwaggerOperations.js +253 -59
  7. package/lib/internal/normalizeSwaggerDocument.js +320 -2
  8. package/lib/structures/ITtscEvidenceDocumentedConfig.d.ts +16 -8
  9. package/lib/structures/ITtscEvidenceGraphClaim.d.ts +6 -0
  10. package/lib/structures/ITtscEvidenceGraphClaimBase.d.ts +8 -2
  11. package/lib/structures/ITtscEvidenceGraphConfig.d.ts +6 -0
  12. package/lib/structures/ITtscEvidenceGraphMarkdownClaim.d.ts +7 -1
  13. package/lib/structures/ITtscEvidenceGraphMarkdownReference.d.ts +7 -1
  14. package/lib/structures/ITtscEvidenceGraphPrismaClaim.d.ts +7 -1
  15. package/lib/structures/ITtscEvidenceGraphPrismaReference.d.ts +7 -1
  16. package/lib/structures/ITtscEvidenceGraphReference.d.ts +10 -4
  17. package/lib/structures/ITtscEvidenceGraphReferenceBase.d.ts +6 -0
  18. package/lib/structures/ITtscEvidenceGraphSwaggerReference.d.ts +6 -0
  19. package/lib/structures/ITtscEvidenceGraphTypeScriptClaim.d.ts +7 -1
  20. package/lib/structures/ITtscEvidenceGraphTypeScriptReference.d.ts +11 -3
  21. package/lib/structures/ITtscEvidenceRules.d.ts +93 -0
  22. package/lib/structures/TtscEvidenceGraphMarkdownSymbol.d.ts +39 -0
  23. package/lib/{typings → structures}/TtscEvidenceGraphPrismaSymbol.d.ts +6 -0
  24. package/lib/{typings → structures}/TtscEvidenceGraphTypeScriptSymbol.d.ts +6 -0
  25. package/lib/structures/index.d.ts +4 -0
  26. package/lib/structures/index.js +4 -0
  27. package/native/base.go +25 -23
  28. package/native/bridge_trace.go +348 -0
  29. package/native/comments.go +1 -1
  30. package/native/config.go +6 -3
  31. package/native/declaration.go +17 -43
  32. package/native/documentation_regions.go +214 -0
  33. package/native/documented.go +5 -4
  34. package/native/exports.go +1 -1
  35. package/native/fingerprint.go +2 -8
  36. package/native/glob.go +4 -1
  37. package/native/graph.go +59 -23
  38. package/native/graph_nodes.go +2 -1
  39. package/native/hints.go +22 -18
  40. package/native/imports.go +7 -6
  41. package/native/input_reader.go +83 -0
  42. package/native/inputs.go +13 -12
  43. package/native/loader.go +36 -24
  44. package/native/markdown.go +587 -177
  45. package/native/markdown_capture.go +223 -0
  46. package/native/model.go +2 -1
  47. package/native/prisma.go +110 -25
  48. package/native/prisma_cache.go +12 -8
  49. package/native/prisma_locator.go +70 -33
  50. package/native/review.go +8 -7
  51. package/native/rooted_typescript.go +3 -4
  52. package/native/swagger.go +123 -15
  53. package/native/swagger_cache.go +16 -12
  54. package/native/todo.go +12 -0
  55. package/native/typescript.go +129 -25
  56. package/package.json +9 -7
  57. package/lib/typings/ITtscLintContributorRules.d.ts +0 -57
  58. package/lib/typings/ITtscLintRuleOptionsMap.d.ts +0 -20
  59. package/lib/typings/ITtscLintRuleOptionsMap.js +0 -2
  60. package/lib/typings/TtscEvidenceGraphMarkdownSymbol.d.ts +0 -32
  61. package/lib/typings/index.d.ts +0 -5
  62. package/lib/typings/index.js +0 -21
  63. /package/lib/{typings/ITtscLintContributorRules.js → structures/ITtscEvidenceRules.js} +0 -0
  64. /package/lib/{typings → structures}/TtscEvidenceGraphMarkdownSymbol.js +0 -0
  65. /package/lib/{typings → structures}/TtscEvidenceGraphPrismaSymbol.js +0 -0
  66. /package/lib/{typings → structures}/TtscEvidenceGraphTypeScriptSymbol.js +0 -0
@@ -2,16 +2,478 @@ package evidence
2
2
 
3
3
  import (
4
4
  "io/fs"
5
- "os"
6
5
  "path/filepath"
7
6
  "regexp"
7
+ "sort"
8
8
  "strings"
9
9
  "unicode"
10
+
11
+ "github.com/yuin/goldmark/v2/ast"
12
+ "github.com/yuin/goldmark/v2/parser"
13
+ "github.com/yuin/goldmark/v2/text"
14
+ "github.com/yuin/goldmark/v2/util"
10
15
  )
11
16
 
12
- var markdownCommentPattern = regexp.MustCompile(`(?s)<!--(.*?)-->`)
13
17
  var explicitAnchorPattern = regexp.MustCompile(`\s*\{#([A-Za-z0-9][A-Za-z0-9._:-]*)\}\s*$`)
14
18
 
19
+ // markdownLexicalLine keeps syntax visibility separate from original content.
20
+ // Heading preserves inline source on its opening line and masks continuations;
21
+ // Prose retains a non-whitespace sentinel for inline examples so a tag after
22
+ // one cannot become line-leading metadata. Neither view changes source offsets.
23
+ type markdownLexicalLine struct {
24
+ Heading string
25
+ Prose string
26
+ }
27
+
28
+ // markdownRegion records a half-open range in the original UTF-8 source.
29
+ type markdownRegion struct {
30
+ Start int
31
+ End int
32
+ Block bool
33
+ }
34
+
35
+ // markdownHybridParser adds the existing HTML/MDX example carriers to a
36
+ // CommonMark parse. It consumes the original reader instead of projecting or
37
+ // deleting bytes, so paragraph and container boundaries remain authoritative.
38
+ type markdownHybridParser struct {
39
+ Source string
40
+ Protected []markdownRegion
41
+ Owners []markdownRegion
42
+ Ends map[ast.Node]int
43
+ }
44
+
45
+ // markdownHybridBlock is deliberately not an ast.Paragraph. Goldmark gives
46
+ // paragraphs its own continuation policy; this node owns example continuations
47
+ // while retaining original source segments for the standard inline parser.
48
+ var markdownHybridBlockKind = ast.NewNodeKind("EvidenceMarkdownExample")
49
+
50
+ type markdownHybridBlock struct{ ast.BaseBlock }
51
+
52
+ func (block *markdownHybridBlock) Kind() ast.NodeKind { return markdownHybridBlockKind }
53
+ func (block *markdownHybridBlock) Dump(_ []byte) *ast.NodeDump { return ast.NewNodeDump(block, nil) }
54
+
55
+ // Free block parsers run after every registered trigger parser regardless of
56
+ // priority. Register every byte so an existing owner can precede fence/list/
57
+ // quote openers, and a new carrier can precede the ordinary HTML block parser.
58
+ var markdownHybridTriggers = func() []byte {
59
+ triggers := make([]byte, 256)
60
+ for index := range triggers {
61
+ triggers[index] = byte(index)
62
+ }
63
+ return triggers
64
+ }()
65
+
66
+ func (scan *markdownHybridParser) Trigger() []byte { return markdownHybridTriggers }
67
+
68
+ func (scan *markdownHybridParser) ownerAt(position int) (markdownRegion, bool) {
69
+ index := sort.Search(len(scan.Owners), func(index int) bool {
70
+ return scan.Owners[index].End > position
71
+ })
72
+ if index < len(scan.Owners) && scan.Owners[index].Start <= position {
73
+ return scan.Owners[index], true
74
+ }
75
+ return markdownRegion{}, false
76
+ }
77
+
78
+ func (scan *markdownHybridParser) protectedAt(position int) (markdownRegion, bool) {
79
+ index := sort.Search(len(scan.Protected), func(index int) bool {
80
+ return scan.Protected[index].End > position
81
+ })
82
+ if index < len(scan.Protected) && scan.Protected[index].Start <= position {
83
+ region := scan.Protected[index]
84
+ if _, owned := scan.ownerAt(region.Start); !owned {
85
+ return region, true
86
+ }
87
+ }
88
+ return markdownRegion{}, false
89
+ }
90
+
91
+ // findOwners records only lexical carriers outside earlier code and comments.
92
+ // A standard-parser code region beginning inside an already acquired hybrid
93
+ // carrier has no authority: the hybrid carrier owns those delimiter bytes.
94
+ func (scan *markdownHybridParser) findOwners(start int, end int) int {
95
+ ownerEnd := start
96
+ for cursor := start; cursor < end; {
97
+ if owner, found := scan.ownerAt(cursor); found {
98
+ ownerEnd = max(ownerEnd, owner.End)
99
+ cursor = owner.End
100
+ continue
101
+ }
102
+ if region, found := scan.protectedAt(cursor); found {
103
+ cursor = region.End
104
+ continue
105
+ }
106
+ if scan.Source[cursor] == '\\' && cursor+1 < len(scan.Source) {
107
+ cursor += 2
108
+ continue
109
+ }
110
+ next := markdownOpaqueEnd(scan.Source, cursor)
111
+ if next > cursor {
112
+ scan.Owners = append(scan.Owners, markdownRegion{Start: cursor, End: next})
113
+ ownerEnd = max(ownerEnd, next)
114
+ cursor = next
115
+ continue
116
+ }
117
+ cursor++
118
+ }
119
+ return ownerEnd
120
+ }
121
+
122
+ func (scan *markdownHybridParser) Open(_ ast.Node, reader text.Reader, _ parser.Context) (ast.Node, parser.State) {
123
+ _, segment := reader.PeekLine()
124
+ end := scan.findOwners(segment.Start, segment.Stop)
125
+ if end <= segment.Stop {
126
+ return nil, parser.NoChildren
127
+ }
128
+ node := &markdownHybridBlock{}
129
+ node.Init(node)
130
+ node.AppendSource(segment)
131
+ scan.Ends[node] = end
132
+ reader.AdvanceToEOL()
133
+ return node, parser.NoChildren
134
+ }
135
+
136
+ func (scan *markdownHybridParser) Continue(node ast.Node, reader text.Reader, _ parser.Context) parser.State {
137
+ line, segment := reader.PeekLine()
138
+ if util.IsBlank(line) || segment.Start >= scan.Ends[node] {
139
+ return parser.Close
140
+ }
141
+ scan.Ends[node] = max(scan.Ends[node], scan.findOwners(segment.Start, segment.Stop))
142
+ node.(ast.BlockNode).AppendSource(segment)
143
+ reader.AdvanceToEOL()
144
+ return parser.Continue | parser.NoChildren
145
+ }
146
+
147
+ func (scan *markdownHybridParser) Close(node ast.Node, _ text.Reader, _ parser.Context) {
148
+ delete(scan.Ends, node)
149
+ }
150
+
151
+ func (scan *markdownHybridParser) CanInterruptParagraph() bool { return true }
152
+ func (scan *markdownHybridParser) CanAcceptIndentedLine() bool { return false }
153
+
154
+ // markdownHybridCarry reacquires a known owner after a paragraph/container
155
+ // boundary, before the bytes inside it can open an unrelated Markdown block.
156
+ // It never acquires new owners and therefore cannot steal ordinary code blocks.
157
+ type markdownHybridCarry struct{ Scan *markdownHybridParser }
158
+
159
+ func (carry *markdownHybridCarry) Trigger() []byte { return markdownHybridTriggers }
160
+ func (carry *markdownHybridCarry) Open(_ ast.Node, reader text.Reader, _ parser.Context) (ast.Node, parser.State) {
161
+ line, segment := reader.PeekLine()
162
+ owner, found := carry.Scan.ownerAt(segment.Start)
163
+ if !found || owner.Start >= segment.Start || util.IsBlank(line) {
164
+ return nil, parser.NoChildren
165
+ }
166
+ node := &markdownHybridBlock{}
167
+ node.Init(node)
168
+ node.AppendSource(segment)
169
+ carry.Scan.Ends[node] = max(owner.End, carry.Scan.findOwners(segment.Start, segment.Stop))
170
+ reader.AdvanceToEOL()
171
+ return node, parser.NoChildren
172
+ }
173
+ func (carry *markdownHybridCarry) Continue(node ast.Node, reader text.Reader, context parser.Context) parser.State {
174
+ return carry.Scan.Continue(node, reader, context)
175
+ }
176
+ func (carry *markdownHybridCarry) Close(node ast.Node, reader text.Reader, context parser.Context) {
177
+ carry.Scan.Close(node, reader, context)
178
+ }
179
+ func (carry *markdownHybridCarry) CanInterruptParagraph() bool { return true }
180
+ func (carry *markdownHybridCarry) CanAcceptIndentedLine() bool { return true }
181
+
182
+ type markdownHybridInline struct{ Scan *markdownHybridParser }
183
+
184
+ func (inline *markdownHybridInline) Trigger() []byte { return []byte{' ', '<', '=', '`'} }
185
+
186
+ func (inline *markdownHybridInline) Parse(parent ast.Node, reader text.Reader, _ parser.Context) ast.Node {
187
+ _, segment := reader.Position()
188
+ owner, found := inline.Scan.ownerAt(segment.Start)
189
+ if !found {
190
+ return nil
191
+ }
192
+ source := parent.(ast.BlockNode).Source()
193
+ end := min(owner.End, source[len(source)-1].Stop)
194
+ start := segment.Start
195
+ for {
196
+ line, segment := reader.PeekLine()
197
+ if line == nil {
198
+ break
199
+ }
200
+ if end <= segment.Stop {
201
+ reader.Advance(end - segment.Start)
202
+ break
203
+ }
204
+ reader.AdvanceLine()
205
+ }
206
+ return ast.NewRawHTML(text.NewMultiLineValueFromIndex(text.NewIndex(start, end), text.IdentityDecoder))
207
+ }
208
+
209
+ // markdownParserRegions reads code positions from the maintained CommonMark
210
+ // parser. Containers and multiline spans use its original source positions.
211
+ func markdownParserRegions(source string, document ast.Node, comments bool) []markdownRegion {
212
+ regions := []markdownRegion{}
213
+ ast.Walk(document, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
214
+ if !entering {
215
+ return ast.WalkContinue, nil
216
+ }
217
+ switch value := node.(type) {
218
+ case *ast.CodeBlock:
219
+ start, end := value.Pos(), value.Pos()
220
+ for _, segment := range value.Value.Segments() {
221
+ end = segment.Stop
222
+ }
223
+ start = strings.LastIndexByte(source[:start], '\n') + 1
224
+ regions = append(regions, markdownRegion{Start: start, End: end, Block: true})
225
+ case *ast.CodeSpan:
226
+ indices := value.Value.Indices()
227
+ if len(indices) != 0 {
228
+ start, end := value.Pos(), indices[len(indices)-1].Stop
229
+ for end < len(source) && source[end] == '`' {
230
+ end++
231
+ }
232
+ regions = append(regions, markdownRegion{Start: start, End: end})
233
+ }
234
+ case *ast.HTMLBlock:
235
+ if comments && value.HTMLBlockKind == ast.HTMLBlockKind2 {
236
+ end := markdownCommentEnd(source, value.Pos())
237
+ regions = append(regions, markdownRegion{Start: value.Pos(), End: end})
238
+ }
239
+ case *ast.RawHTML:
240
+ if comments && strings.HasPrefix(source[value.Pos():], "<!--") {
241
+ indices := value.Value.Indices()
242
+ if len(indices) != 0 {
243
+ regions = append(regions, markdownRegion{Start: value.Pos(), End: indices[len(indices)-1].Stop})
244
+ }
245
+ }
246
+ }
247
+ return ast.WalkContinue, nil
248
+ })
249
+ sort.Slice(regions, func(left, right int) bool { return regions[left].Start < regions[right].Start })
250
+ return regions
251
+ }
252
+
253
+ // scanMarkdownRegions gives every annotation consumer the same original-byte
254
+ // classification. Two parser passes preserve CommonMark ownership before adding
255
+ // the existing permissive MDX carriers through public parser extension APIs.
256
+ // No rendered text or normalized projection participates in source offsets.
257
+ func scanMarkdownRegions(content string, lines []string) ([]markdownLexicalLine, [][2]int) {
258
+ baseline := parser.New().Parse([]byte(content))
259
+ scan := &markdownHybridParser{Source: content, Protected: markdownParserRegions(content, baseline, true), Ends: map[ast.Node]int{}}
260
+ // All block entries share one receiver's ownership state. A known owner
261
+ // resumes before new blocks; headings acquire carriers before ATX parsing,
262
+ // and ordinary carriers acquire ownership after code and quote openers.
263
+ document := parser.New(parser.WithBlockParsers(
264
+ util.Prioritized[parser.BlockParser](&markdownHybridCarry{Scan: scan}, 0),
265
+ util.Prioritized[parser.BlockParser](&markdownHybridHeading{Scan: scan}, 590),
266
+ util.Prioritized[parser.BlockParser](scan, 850),
267
+ ), parser.WithInlineParsers(util.Prioritized[parser.InlineParser](&markdownHybridInline{Scan: scan}, 50))).Parse([]byte(content))
268
+ regions := markdownParserRegions(content, document, false)
269
+ code := make([]byte, len(content))
270
+ for _, region := range regions {
271
+ kind := byte(1)
272
+ if region.Block {
273
+ kind = 2
274
+ }
275
+ for cursor := region.Start; cursor < region.End; cursor++ {
276
+ code[cursor] = kind
277
+ }
278
+ }
279
+ lexical := make([]markdownLexicalLine, len(lines))
280
+ spans := [][2]int{}
281
+ commentStart := -1
282
+ opaqueStart, opaqueEnd := 0, 0
283
+ offset := 0
284
+ for index, rawLine := range lines {
285
+ line := strings.TrimSuffix(rawLine, "\r")
286
+ heading, prose := []byte(line), []byte(line)
287
+ mask := func(start, end int, structural bool) {
288
+ for cursor := start; cursor < end; cursor++ {
289
+ prose[cursor] = ' '
290
+ if structural {
291
+ heading[cursor] = ' '
292
+ }
293
+ }
294
+ }
295
+ for cursor := 0; cursor < len(line); {
296
+ if commentStart >= 0 {
297
+ end, closed := len(line), false
298
+ if offset+cursor == commentStart+4 && strings.HasPrefix(line[cursor:], ">") {
299
+ end, closed = cursor+1, true
300
+ } else if offset+cursor == commentStart+4 && strings.HasPrefix(line[cursor:], "->") {
301
+ end, closed = cursor+2, true
302
+ } else if closing := strings.Index(line[cursor:], "-->"); closing >= 0 {
303
+ end, closed = cursor+closing+3, true
304
+ }
305
+ mask(cursor, end, true)
306
+ if commentStart < offset {
307
+ heading[cursor] = 'x'
308
+ }
309
+ cursor = end
310
+ if closed {
311
+ spans = append(spans, [2]int{commentStart, offset + end})
312
+ commentStart = -1
313
+ }
314
+ continue
315
+ }
316
+ if opaqueEnd > offset+cursor {
317
+ end := min(len(line), opaqueEnd-offset)
318
+ continuation := opaqueStart < offset
319
+ mask(cursor, end, continuation)
320
+ if continuation {
321
+ heading[cursor] = 'x'
322
+ }
323
+ prose[cursor] = 'x'
324
+ cursor = end
325
+ continue
326
+ }
327
+ if kind := code[offset+cursor]; kind != 0 {
328
+ end := cursor + 1
329
+ for end < len(line) && code[offset+end] == kind {
330
+ end++
331
+ }
332
+ mask(cursor, end, kind == 2)
333
+ prose[cursor] = 'x'
334
+ cursor = end
335
+ continue
336
+ }
337
+ if line[cursor] == '\\' && cursor+1 < len(line) && strings.ContainsRune("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~", rune(line[cursor+1])) {
338
+ mask(cursor, cursor+2, false)
339
+ prose[cursor] = 'x'
340
+ cursor += 2
341
+ continue
342
+ }
343
+ if strings.HasPrefix(line[cursor:], "<!--") {
344
+ commentStart = offset + cursor
345
+ mask(cursor, cursor+4, true)
346
+ cursor += 4
347
+ continue
348
+ }
349
+ if end := markdownOpaqueEnd(content, offset+cursor); end > offset+cursor {
350
+ opaqueStart, opaqueEnd = offset+cursor, end
351
+ continue
352
+ }
353
+ cursor++
354
+ }
355
+ lexical[index] = markdownLexicalLine{Heading: string(heading), Prose: string(prose)}
356
+ offset += len(rawLine) + 1
357
+ }
358
+ return lexical, spans
359
+ }
360
+
361
+ type markdownHybridHeading struct {
362
+ Scan *markdownHybridParser
363
+ }
364
+
365
+ func (heading *markdownHybridHeading) Trigger() []byte { return []byte{'#'} }
366
+ func (heading *markdownHybridHeading) Open(parent ast.Node, reader text.Reader, context parser.Context) (ast.Node, parser.State) {
367
+ line, segment := reader.PeekLine()
368
+ if _, _, found := markdownHeading(string(line)); !found {
369
+ return nil, parser.NoChildren
370
+ }
371
+ node, state := heading.Scan.Open(parent, reader, context)
372
+ if node != nil {
373
+ // An ATX heading owns exactly one source line. Its rendered carrier may
374
+ // continue, but backticks in the heading cannot pair with the next block.
375
+ heading.Scan.Ends[node] = segment.Stop
376
+ }
377
+ return node, state
378
+ }
379
+ func (heading *markdownHybridHeading) Continue(node ast.Node, reader text.Reader, context parser.Context) parser.State {
380
+ return heading.Scan.Continue(node, reader, context)
381
+ }
382
+ func (heading *markdownHybridHeading) Close(node ast.Node, reader text.Reader, context parser.Context) {
383
+ heading.Scan.Close(node, reader, context)
384
+ }
385
+ func (heading *markdownHybridHeading) CanInterruptParagraph() bool { return true }
386
+ func (heading *markdownHybridHeading) CanAcceptIndentedLine() bool { return false }
387
+
388
+ // markdownOpaqueEnd consumes supported HTML syntax and template attributes with
389
+ // quote-aware original-byte boundaries. An unclosed rendered owner retains its
390
+ // suffix, matching the existing permissive Markdown/MDX documentation contract.
391
+ func markdownOpaqueEnd(content string, start int) int {
392
+ if strings.HasPrefix(content[start:], "={`") {
393
+ return markdownTemplateEnd(content, start+3)
394
+ }
395
+ if content[start] != '<' || start+1 >= len(content) {
396
+ return start
397
+ }
398
+ cursor := start + 1
399
+ closing := content[cursor] == '/'
400
+ if closing {
401
+ cursor++
402
+ }
403
+ name := cursor
404
+ for cursor < len(content) && (content[cursor] >= 'a' && content[cursor] <= 'z' || content[cursor] >= 'A' && content[cursor] <= 'Z' || cursor > name && (content[cursor] >= '0' && content[cursor] <= '9' || content[cursor] == '-' || content[cursor] == ':')) {
405
+ cursor++
406
+ }
407
+ if cursor == name {
408
+ return start
409
+ }
410
+ if cursor < len(content) && !strings.ContainsRune(" \t\r\n/>", rune(content[cursor])) {
411
+ return start
412
+ }
413
+ pre := !closing && strings.EqualFold(content[name:cursor], "pre")
414
+ quote := byte(0)
415
+ for cursor < len(content) {
416
+ char := content[cursor]
417
+ if quote != 0 {
418
+ if char == quote {
419
+ quote = 0
420
+ }
421
+ cursor++
422
+ } else if char == '\'' || char == '"' {
423
+ quote = char
424
+ cursor++
425
+ } else if strings.HasPrefix(content[cursor:], "={`") {
426
+ cursor = markdownTemplateEnd(content, cursor+3)
427
+ } else if char == '>' {
428
+ cursor++
429
+ if pre {
430
+ for probe := cursor; probe+6 <= len(content); probe++ {
431
+ if strings.EqualFold(content[probe:probe+6], "</pre>") {
432
+ return probe + 6
433
+ }
434
+ }
435
+ return len(content)
436
+ }
437
+ return cursor
438
+ } else if char == '<' {
439
+ return start
440
+ } else {
441
+ cursor++
442
+ }
443
+ }
444
+ if pre {
445
+ return len(content)
446
+ }
447
+ return start
448
+ }
449
+
450
+ // markdownCommentEnd clips a protected comment to its actual closing byte;
451
+ // CommonMark HTML blocks may retain unrelated text from the closing line.
452
+ func markdownCommentEnd(content string, start int) int {
453
+ body := start + 4
454
+ if strings.HasPrefix(content[body:], ">") {
455
+ return body + 1
456
+ }
457
+ if strings.HasPrefix(content[body:], "->") {
458
+ return body + 2
459
+ }
460
+ if close := strings.Index(content[body:], "-->"); close >= 0 {
461
+ return body + close + 3
462
+ }
463
+ return len(content)
464
+ }
465
+
466
+ func markdownTemplateEnd(content string, from int) int {
467
+ for cursor := from; cursor < len(content); cursor++ {
468
+ if content[cursor] == '\\' && cursor+1 < len(content) {
469
+ cursor++
470
+ } else if strings.HasPrefix(content[cursor:], "`}") {
471
+ return cursor + 2
472
+ }
473
+ }
474
+ return len(content)
475
+ }
476
+
15
477
  // loadMarkdownInventories reads every configured Markdown population, once per
16
478
  // distinct base.
17
479
  //
@@ -23,13 +485,18 @@ var explicitAnchorPattern = regexp.MustCompile(`\s*\{#([A-Za-z0-9][A-Za-z0-9._:-
23
485
  func loadMarkdownInventories(
24
486
  root string,
25
487
  config graphConfig,
488
+ captures ...*markdownCapture,
26
489
  ) (map[string]*artifactInventory, graphDiagnostics) {
490
+ capture := &markdownCapture{}
491
+ if len(captures) != 0 && captures[0] != nil {
492
+ capture = captures[0]
493
+ }
27
494
  inventories := map[string]*artifactInventory{}
28
495
  problems := graphDiagnostics{}
29
496
  for _, base := range configuredBases(config, artifactMarkdown) {
30
497
  problems = append(
31
498
  problems,
32
- loadMarkdownBase(base, config, inventories)...,
499
+ loadMarkdownBase(base, config, inventories, capture)...,
33
500
  )
34
501
  }
35
502
  return inventories, problems
@@ -39,19 +506,35 @@ func loadMarkdownBase(
39
506
  base populationBase,
40
507
  config graphConfig,
41
508
  inventories map[string]*artifactInventory,
509
+ capture *markdownCapture,
42
510
  ) graphDiagnostics {
43
511
  problems := graphDiagnostics{}
44
512
  severity := populationSeverity(config, artifactMarkdown, base, "", "*", false)
45
- if problem := baseDirectoryProblem(base, artifactMarkdown); problem != "" {
513
+ from, problem := capture.resolve(base)
514
+ if problem != "" {
46
515
  recordPopulationFailure(inventories, artifactMarkdown, base)
47
516
  return problems.add(severity, problem)
48
517
  }
49
- from, resolved := resolvedBaseDirectory(base)
50
- if !resolved {
51
- recordPopulationFailure(inventories, artifactMarkdown, base)
52
- return problems.add(severity, unresolvedBaseProblem(base, artifactMarkdown))
518
+ project := func(relative string, inventory *artifactInventory, readErr error) {
519
+ address := base.addressOf(relative)
520
+ inventories[address.Key] = inventory
521
+ severity := populationSeverity(config, artifactMarkdown, base, relative, "*", false)
522
+ if readErr != nil {
523
+ problems = problems.add(severity,
524
+ "Evidence graph could not read Markdown file '"+address.Display+"': "+causeText(readErr)+". Fix filesystem access or exclude the file from configured globs.",
525
+ )
526
+ return
527
+ }
528
+ for _, inventoryProblem := range inventory.Problems {
529
+ if selectedByMarkdownPopulation(config, base, relative, inventoryProblem.Symbol) {
530
+ problems = problems.add(populationSeverity(config, artifactMarkdown, base, relative, inventoryProblem.Symbol, false), inventoryProblem.Message)
531
+ }
532
+ }
533
+ // Unreadable tags concern the selected file, independently of its symbols
534
+ // or health. They retain the same phase-specific file severity.
535
+ problems = problems.add(severity, inventory.Unreadable...)
53
536
  }
54
- err := filepath.WalkDir(from, func(current string, entry fs.DirEntry, walkErr error) error {
537
+ err := capture.walk(base, from, config, func(current string, entry fs.DirEntry, walkErr error) error {
55
538
  if walkErr != nil {
56
539
  // The walk root belongs to its population by construction, so a failure
57
540
  // to list it is a failure of the population and is never decided by what
@@ -107,35 +590,20 @@ func loadMarkdownBase(
107
590
  if !matchesConfiguredMarkdownFile(config, base, relative) {
108
591
  return nil
109
592
  }
110
- severity := populationSeverity(config, artifactMarkdown, base, relative, "*", false)
111
593
  address := base.addressOf(relative)
112
- content, readErr := os.ReadFile(current)
113
- if readErr != nil {
114
- inventories[address.Key] = &artifactInventory{
115
- Path: address.Display,
116
- Type: artifactMarkdown,
117
- LoadFailed: true,
118
- }
119
- problems = problems.add(
120
- severity,
121
- "Evidence graph could not read Markdown file '"+address.Display+"': "+causeText(readErr)+". Fix filesystem access or exclude the file from configured globs.",
122
- )
123
- return nil
124
- }
125
- inventory, _ := scanMarkdownInventory(address, string(content))
126
- inventories[address.Key] = inventory
127
- for _, inventoryProblem := range inventory.Problems {
128
- if selectedByMarkdownPopulation(config, base, relative, inventoryProblem.Symbol) {
129
- problems = problems.add(populationSeverity(config, artifactMarkdown, base, relative, inventoryProblem.Symbol, false), inventoryProblem.Message)
130
- }
131
- }
132
- // An unreadable tag is not a health question and not a symbol question
133
- // either: the file loaded, its units are complete, and the tag reaches no
134
- // host whichever symbol a reference selects. The walk already refuses a
135
- // path no configured glob takes, so reaching here is enough to report.
136
- problems = problems.add(severity, inventory.Unreadable...)
594
+ inventory, readErr := capture.read(address, current)
595
+ project(relative, inventory, readErr)
137
596
  return nil
138
597
  })
598
+ // An expanded reference selection may need fresh discovery. Files already
599
+ // consumed remain part of this Check's snapshot even if that later walk no
600
+ // longer sees them. Reproject their original inventory and failures only
601
+ // where this phase selects the same address.
602
+ for relative, captured := range capture.inventories[base.Absolute] {
603
+ if _, seen := inventories[base.address(relative)]; !seen && matchesConfiguredMarkdownFile(config, base, relative) {
604
+ project(relative, captured.inventory, captured.err)
605
+ }
606
+ }
139
607
  if err != nil {
140
608
  recordPopulationFailure(inventories, artifactMarkdown, base)
141
609
  problems = problems.add(severity, unlistableBaseProblem(base, "Markdown", err))
@@ -180,14 +648,14 @@ func scanMarkdownInventory(
180
648
  })
181
649
  }
182
650
 
651
+ // A byte order mark is encoding, not content. A document that opens with one
652
+ // renders its first heading, but the mark would sit in front of the `#` and
653
+ // hide that heading from the scan, so its unit and every obligation it owes
654
+ // would vanish without a word.
655
+ content = strings.TrimPrefix(content, "\xef\xbb\xbf")
183
656
  lines := strings.Split(content, "\n")
184
657
  hostAtLine := make([]string, len(lines))
185
658
  hostIDAtLine := make([]string, len(lines))
186
- fencedAtLine := make([]bool, len(lines))
187
- // commentAtLine marks the lines a citation or a review can live on, so the
188
- // content digest can leave them out. A fenced block is not marked: an
189
- // `<!-- -->` inside one hosts no tag, and its text is content of the section.
190
- commentAtLine := make([]bool, len(lines))
191
659
  // The nearest heading *unit* enclosing each line, which is not the same as its
192
660
  // host: a heading may open a region without materializing a unit. Kept apart
193
661
  // from hostIDAtLine because that value decides where a declaration sits, and
@@ -196,62 +664,11 @@ func scanMarkdownInventory(
196
664
  currentDigestHostID := fileUnitID
197
665
  currentHost := "file"
198
666
  currentHostID := fileUnitID
199
- fenceMarker := rune(0)
200
- fenceLength := 0
201
- inHTMLComment := false
667
+ lexical, commentSpans := scanMarkdownRegions(content, lines)
202
668
  headingUnitIDs := [5]string{}
203
- for index, rawLine := range lines {
204
- line := strings.TrimSuffix(rawLine, "\r")
205
- trimmed := strings.TrimLeft(line, " \t")
206
- if marker, length, remainder, ok := markdownFence(line); ok {
207
- fencedAtLine[index] = true
208
- hostIDAtLine[index] = currentHostID
209
- // Fenced content is content. It hosts no tag, so it is never excluded as a
210
- // tag position, and leaving it unattributed would drop every code block out
211
- // of its section's digest: rewriting the example in a cited section would
212
- // then expire nothing.
213
- digestHostIDAtLine[index] = currentDigestHostID
214
- if fenceMarker == 0 {
215
- fenceMarker = marker
216
- fenceLength = length
217
- } else if marker == fenceMarker &&
218
- length >= fenceLength &&
219
- strings.TrimSpace(remainder) == "" {
220
- fenceMarker = 0
221
- fenceLength = 0
222
- }
223
- hostAtLine[index] = currentHost
224
- continue
225
- }
226
- if fenceMarker != 0 {
227
- fencedAtLine[index] = true
228
- hostAtLine[index] = currentHost
229
- hostIDAtLine[index] = currentHostID
230
- digestHostIDAtLine[index] = currentDigestHostID
231
- continue
232
- }
233
- if inHTMLComment {
234
- if strings.Contains(trimmed, "-->") {
235
- inHTMLComment = false
236
- }
237
- hostAtLine[index] = currentHost
238
- hostIDAtLine[index] = currentHostID
239
- digestHostIDAtLine[index] = currentDigestHostID
240
- commentAtLine[index] = true
241
- continue
242
- }
243
- if strings.HasPrefix(trimmed, "<!--") {
244
- remainder := strings.TrimPrefix(trimmed, "<!--")
245
- if !strings.Contains(remainder, "-->") {
246
- inHTMLComment = true
247
- }
248
- hostAtLine[index] = currentHost
249
- hostIDAtLine[index] = currentHostID
250
- digestHostIDAtLine[index] = currentDigestHostID
251
- commentAtLine[index] = true
252
- continue
253
- }
254
- level, title, ok := markdownHeading(line)
669
+ for index := range lines {
670
+ visible := lexical[index].Heading
671
+ level, title, ok := markdownHeading(visible)
255
672
  if ok {
256
673
  currentHost = "h" + decimal(level)
257
674
  currentHostID = "markdown:" + address.Key + ":" + currentHost + ":" + decimal(index+1)
@@ -333,19 +750,22 @@ func scanMarkdownInventory(
333
750
  digestHostIDAtLine[index] = currentDigestHostID
334
751
  }
335
752
 
336
- reportUnreadableMarkdownTags(inventory, address.Display, lines, fencedAtLine, commentAtLine)
753
+ reportUnreadableMarkdownTags(inventory, address.Display, lexical)
337
754
 
338
755
  sequence := 0
339
- for _, match := range markdownCommentPattern.FindAllStringSubmatchIndex(content, -1) {
340
- if len(match) < 4 {
341
- continue
342
- }
756
+ for _, match := range commentSpans {
343
757
  commentStart := match[0]
344
758
  line := lineAt(content, commentStart)
345
- if line <= 0 || line > len(lines) || fencedAtLine[line-1] {
759
+ if line <= 0 || line > len(lines) {
760
+ continue
761
+ }
762
+ // `<!-->` and `<!--->` are complete comments with no body, so their closing
763
+ // marker overlaps their opening one and there is nothing to parse.
764
+ bodyStart, bodyEnd := match[0]+4, match[1]-3
765
+ if bodyEnd < bodyStart {
346
766
  continue
347
767
  }
348
- comment := content[match[2]:match[3]]
768
+ comment := content[bodyStart:bodyEnd]
349
769
  for _, parsed := range parseDeclarations(comment) {
350
770
  sequence++
351
771
  inventory.Declarations = append(inventory.Declarations, &evidenceDeclaration{
@@ -375,7 +795,7 @@ func scanMarkdownInventory(
375
795
  })
376
796
  }
377
797
  }
378
- assignMarkdownDigests(inventory, lines, digestHostIDAtLine, commentAtLine)
798
+ assignMarkdownDigests(inventory, content, lines, digestHostIDAtLine, commentSpans)
379
799
  return inventory, problems
380
800
  }
381
801
 
@@ -395,34 +815,72 @@ func scanMarkdownInventory(
395
815
  // `evidenceUnit.Digest` records the consequence; do not carry the Markdown
396
816
  // intuition across.
397
817
  //
398
- // HTML comment lines are dropped because that is where a Markdown citation and
399
- // its review live. Leaving them in would make writing the review change the
400
- // digest the review's own fingerprint is checked against.
818
+ // The text cut out of every digest is exactly what the declaration scan reads
819
+ // as a tag position: each closed HTML comment outside code and rendered
820
+ // examples. The shared lexical scan records exact original-byte spans, so a
821
+ // span may open after prose, close before prose, or run
822
+ // across lines, and it may not be a whole line. Cutting spans rather than lines
823
+ // keeps the prose beside a comment in the digest, so a content change there
824
+ // still expires a review, while writing the review changes nothing it is
825
+ // checked against. A `<!--` that never closes matches no span, is read as no
826
+ // tag, and so stays content.
827
+ //
828
+ // A line left holding nothing but comment spans is dropped, and so is the one
829
+ // blank line after it when a blank line stood before it. A comment written as a
830
+ // paragraph of its own is set off by blank lines on both sides, and keeping
831
+ // both would let adding or removing the comment add or remove a blank line of
832
+ // the unit it sits in, which is the tag position changing the digest after all.
401
833
  func assignMarkdownDigests(
402
834
  inventory *artifactInventory,
835
+ content string,
403
836
  lines []string,
404
837
  digestHostIDAtLine []string,
405
- commentAtLine []bool,
838
+ spans [][2]int,
406
839
  ) {
407
840
  owned := map[string][]string{}
408
- for index := range lines {
841
+ next := 0
842
+ lineStart := 0
843
+ // Whether the last line kept was blank, and whether the blank line that
844
+ // follows a dropped comment paragraph is the second of its pair.
845
+ keptBlank := false
846
+ dropBlank := false
847
+ for index, rawLine := range lines {
848
+ lineEnd := lineStart + len(rawLine)
409
849
  id := digestHostIDAtLine[index]
410
- if index < len(commentAtLine) && commentAtLine[index] {
411
- continue
850
+ for next < len(spans) && spans[next][1] <= lineStart {
851
+ next++
412
852
  }
853
+ remainder := strings.Builder{}
854
+ cursor := lineStart
855
+ cut := false
856
+ for k := next; k < len(spans) && spans[k][0] < lineEnd; k++ {
857
+ cut = true
858
+ if spans[k][0] > cursor {
859
+ remainder.WriteString(content[cursor:spans[k][0]])
860
+ }
861
+ cursor = max(cursor, min(spans[k][1], lineEnd))
862
+ }
863
+ if cursor < lineEnd {
864
+ remainder.WriteString(content[cursor:lineEnd])
865
+ }
866
+ lineStart = lineEnd + 1
867
+ text := strings.TrimSuffix(remainder.String(), "\r")
413
868
  if id == "" {
414
869
  continue
415
870
  }
416
- // A comment opening after prose on the same line is still a tag position:
417
- // the declaration scan runs over the whole document, so it finds a citation
418
- // or a review there. Only the comment span comes out, never the line, or the
419
- // prose beside it would vanish from the digest and a real content change
420
- // would stop expiring anything.
421
- content := markdownCommentPattern.ReplaceAllString(
422
- strings.TrimSuffix(lines[index], "\r"),
423
- "",
424
- )
425
- owned[id] = append(owned[id], content)
871
+ blank := strings.TrimSpace(text) == ""
872
+ // A line holding nothing but comment spans is a tag position, not content.
873
+ if cut && blank {
874
+ dropBlank = keptBlank
875
+ continue
876
+ }
877
+ if blank && dropBlank {
878
+ dropBlank = false
879
+ continue
880
+ }
881
+ dropBlank = false
882
+ keptBlank = blank
883
+ owned[id] = append(owned[id], text)
426
884
  }
427
885
  for _, unit := range inventory.Units {
428
886
  unit.Digest = contentDigest(strings.Join(owned[unit.ID], "\n"))
@@ -612,8 +1070,10 @@ func markdownSlug(title string) string {
612
1070
  // A fenced block is an example rather than a citation and stays silent, which
613
1071
  // is not a concession: this product's own documentation shows tags inside
614
1072
  // fences, and reporting them would fail its build. An indented code block is
615
- // the same case in another spelling, so four leading spaces are read as code
616
- // rather than as prose.
1073
+ // the same case in another spelling. The maintained CommonMark parser decides
1074
+ // indentation relative to the enclosing list and quote containers. Inline code,
1075
+ // escaped delimiters and the supported HTML/MDX example carriers stay silent
1076
+ // under the same lexical classification used by headings and metadata.
617
1077
  //
618
1078
  // The tag has to open its line, which is the discrimination every reader in
619
1079
  // this package performs, so a sentence mentioning one describes it rather than
@@ -621,45 +1081,19 @@ func markdownSlug(title string) string {
621
1081
  func reportUnreadableMarkdownTags(
622
1082
  inventory *artifactInventory,
623
1083
  location string,
624
- lines []string,
625
- fencedAtLine []bool,
626
- commentAtLine []bool,
1084
+ lexical []markdownLexicalLine,
627
1085
  ) {
628
1086
  if inventory == nil {
629
1087
  return
630
1088
  }
631
- rendered := false
632
- for index, rawLine := range lines {
633
- line := strings.TrimSuffix(rawLine, "\r")
634
- if opens, closes := renderedCodeEdges(line); opens || closes {
635
- rendered = opens
636
- continue
637
- }
638
- if rendered {
639
- continue
640
- }
641
- if index < len(fencedAtLine) && fencedAtLine[index] {
642
- continue
643
- }
644
- if index < len(commentAtLine) && commentAtLine[index] {
645
- continue
646
- }
647
- if strings.HasPrefix(line, " ") || strings.HasPrefix(line, " ") {
648
- continue
649
- }
650
- trimmed := markdownLineContent(line)
1089
+ for index, line := range lexical {
1090
+ trimmed := markdownLineContent(line.Prose)
651
1091
  if tag, _, found := declarationLine(trimmed); found {
652
- inventory.Unreadable = append(
653
- inventory.Unreadable,
654
- unreadableMarkdownProblem("@"+string(tag), location, index+1),
655
- )
1092
+ inventory.Unreadable = append(inventory.Unreadable, unreadableMarkdownProblem("@"+string(tag), location, index+1))
656
1093
  continue
657
1094
  }
658
1095
  if reviews, _, opened := reviewLine(trimmed); opened {
659
- inventory.Unreadable = append(
660
- inventory.Unreadable,
661
- unreadableMarkdownProblem(reviewMarkerFor(reviews), location, index+1),
662
- )
1096
+ inventory.Unreadable = append(inventory.Unreadable, unreadableMarkdownProblem(reviewMarkerFor(reviews), location, index+1))
663
1097
  }
664
1098
  }
665
1099
  }
@@ -711,30 +1145,6 @@ func markdownListMarker(content string) int {
711
1145
  return digits + 2
712
1146
  }
713
1147
 
714
- // renderedCodeEdges reports whether a line opens or closes a block that renders
715
- // as code without being a fence.
716
- //
717
- // A documentation site shows examples through more than one syntax. An MDX page
718
- // passes a template literal to a component, and an HTML page uses `<pre>`; both
719
- // render as code, so both are examples in the sense a fence is, and the repair
720
- // this diagnostic names would delete the example from the rendered page rather
721
- // than fix anything. Only the two edges are recognized, because a page that
722
- // opens one and never closes it is a page whose own build fails first.
723
- func renderedCodeEdges(line string) (bool, bool) {
724
- lowered := strings.ToLower(line)
725
- switch {
726
- case strings.Contains(lowered, "<pre"):
727
- return !strings.Contains(lowered, "</pre>"), strings.Contains(lowered, "</pre>")
728
- case strings.Contains(lowered, "</pre>"):
729
- return false, true
730
- case strings.Contains(line, "={`"):
731
- return !strings.Contains(line, "`}"), strings.Contains(line, "`}")
732
- case strings.Contains(line, "`}"):
733
- return false, true
734
- }
735
- return false, false
736
- }
737
-
738
1148
  // unreadableMarkdownProblem names the position and the move that fixes it.
739
1149
  func unreadableMarkdownProblem(tag string, location string, line int) string {
740
1150
  return "Unreadable " + tag + " at " + location + ":" + decimal(line) +