@ttsc/evidence 0.30.4 → 0.31.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.
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
@@ -1,7 +1,6 @@
1
1
  package evidence
2
2
 
3
3
  import (
4
- "os"
5
4
  "strings"
6
5
  "unicode"
7
6
  )
@@ -23,14 +22,16 @@ const (
23
22
  prismaBlockComment prismaCommentForm = "block"
24
23
  )
25
24
 
26
- // prismaCommentRun is one contiguous comment block and the declaration it
27
- // attaches to.
25
+ // prismaCommentRun is one comment run and the declaration it attaches to.
28
26
  //
29
- // Body preserves the run's own line spacing — a line the run skipped is empty
30
- // rather than absent — so a tag's offset inside the body is still its offset in
31
- // the file. Key is the declaration the run documents, empty when it documents
32
- // nothing unless FileLevel identifies a top-level triple-slash exclusion
33
- // carrier.
27
+ // A run is either the contiguous documentation block above one declaration or a
28
+ // single comment line that can never document one: a `//` line, or a comment
29
+ // that shares its line with code. Body preserves a documentation run's own line
30
+ // spacing — a line the run skipped is empty rather than absent — so a tag's
31
+ // offset inside the body is still its offset in the file. Key is the
32
+ // declaration the run documents, empty when it documents nothing unless
33
+ // FileLevel identifies a top-level triple-slash exclusion carrier. Trailing
34
+ // marks the single-line run whose comment shares its line with code.
34
35
  type prismaCommentRun struct {
35
36
  Form prismaCommentForm
36
37
  Path string
@@ -38,6 +39,7 @@ type prismaCommentRun struct {
38
39
  Body string
39
40
  Key string
40
41
  FileLevel bool
42
+ Trailing bool
41
43
  }
42
44
 
43
45
  // prismaFileScan is everything one schema file yields to the native side.
@@ -46,12 +48,20 @@ type prismaFileScan struct {
46
48
  Comments []prismaCommentRun
47
49
  }
48
50
 
51
+ // prismaByteOrderMark is the UTF-8 encoding of U+FEFF, spelled as bytes so the
52
+ // constant is visible in an editor that hides the character.
53
+ const prismaByteOrderMark = "\xef\xbb\xbf"
54
+
55
+ // prismaLineBreaks rewrites every line terminator Prisma's grammar accepts to
56
+ // `\n`, taking `\r\n` as one terminator rather than two.
57
+ var prismaLineBreaks = strings.NewReplacer("\r\n", "\n", "\r", "\n")
58
+
49
59
  // prismaBlockKeywords are the top-level block openers Prisma's grammar accepts.
50
60
  //
51
- // All six are recognized even though only `model`, `view`, and `type` can own a
52
- // unit, because recognizing a block is how the scan knows when it is *not*
53
- // inside one. Dropping `datasource` and `generator` would leave their settings
54
- // looking like members of whatever block was declared before them.
61
+ // All six are recognized even though only `model` and `view` own a unit, because
62
+ // recognizing a block is how the scan knows when it is *not* inside one.
63
+ // Dropping `datasource` and `generator` would leave their settings looking like
64
+ // members of whatever block was declared before them.
55
65
  var prismaBlockKeywords = map[string]bool{
56
66
  "model": true,
57
67
  "view": true,
@@ -61,7 +71,13 @@ var prismaBlockKeywords = map[string]bool{
61
71
  "generator": true,
62
72
  }
63
73
 
64
- // prismaMemberBlocks are the blocks whose members this graph addresses.
74
+ // prismaMemberBlocks are the blocks whose names and members the scan locates.
75
+ //
76
+ // Locating a block is not owning a unit for it. A `type` block is a composite
77
+ // type, which is outside the unit model: the parser reports it apart from the
78
+ // models the units are built from, so its locations are recorded and never
79
+ // looked up, and a citation written in one is reported as documenting something
80
+ // that is not a model, column, or relation.
65
81
  var prismaMemberBlocks = map[string]bool{
66
82
  "model": true,
67
83
  "view": true,
@@ -90,11 +106,13 @@ var prismaMemberBlocks = map[string]bool{
90
106
  func locatePrismaDeclarations(
91
107
  root string,
92
108
  sources []string,
109
+ readers ...evidenceInputReader,
93
110
  ) (map[string]prismaLocation, []prismaCommentRun) {
111
+ inputs := inputReader(readers)
94
112
  locations := map[string]prismaLocation{}
95
113
  comments := []prismaCommentRun{}
96
114
  for _, source := range sources {
97
- content, err := os.ReadFile(resolveProjectPath(root, source))
115
+ content, err := inputs.ReadFile(resolveProjectPath(root, source))
98
116
  if err != nil {
99
117
  continue
100
118
  }
@@ -120,14 +138,27 @@ func locatePrismaDeclarations(
120
138
  // - A run followed by a block attribute or by the block's closing brace
121
139
  // documents nothing.
122
140
  //
141
+ // A comment that shares its line with code never joins a run. Prisma reads a
142
+ // trailing `///` or `/* */` as documentation of the field on its own line, and
143
+ // never of the declaration below it, and this graph reads a citation only from
144
+ // the lines above a declaration, so such a comment is reported instead.
145
+ //
123
146
  // A run that documents nothing keeps an empty Key rather than being dropped,
124
147
  // because a citation written there is a citation that will never be honoured —
125
148
  // and this rule reports that rather than letting it pass for a tag that works.
149
+ //
150
+ // The scan reads the text the parser was handed, not the bytes on disk: the
151
+ // bridge decodes a file as UTF-8, which drops a leading byte order mark, and
152
+ // Prisma's grammar ends a line at `\n`, `\r\n`, or a lone `\r`. A scan that kept
153
+ // the mark would fail to recognise a first-line block opener or to attach a
154
+ // first-line comment, and one that split on `\n` alone would see a lone-`\r`
155
+ // file as a single line.
126
156
  func scanPrismaFile(
127
157
  source string,
128
158
  content string,
129
159
  locations map[string]prismaLocation,
130
160
  ) prismaFileScan {
161
+ content = prismaLineBreaks.Replace(strings.TrimPrefix(content, prismaByteOrderMark))
131
162
  scan := prismaFileScan{Locations: locations}
132
163
  record := func(key string, line int) {
133
164
  if key == "" {
@@ -155,8 +186,7 @@ func scanPrismaFile(
155
186
  pending = nil
156
187
  }
157
188
  for index, raw := range lines {
158
- line := strings.TrimSuffix(raw, "\r")
159
- code, comment, form, stillCommented := prismaPartsOf(line, commented)
189
+ code, comment, form, stillCommented := prismaPartsOf(raw, commented)
160
190
  commented = stillCommented
161
191
  trimmed := strings.TrimSpace(code)
162
192
  if comment != "" || form != "" {
@@ -222,9 +252,9 @@ type prismaPendingComment struct {
222
252
  Form prismaCommentForm
223
253
  Line int
224
254
  Text string
225
- // Trailing marks a comment that shared its line with code. Prisma treats
226
- // such a comment as the field's trailing comment rather than as
227
- // documentation, so it can never document the declaration below it.
255
+ // Trailing marks a comment that shared its line with code. Such a comment
256
+ // belongs to the line it sits on, never to the declaration below it, so it
257
+ // cannot join the documentation run above that declaration.
228
258
  Trailing bool
229
259
  // TopLevel marks a comment written outside every Prisma block. A detached
230
260
  // documentation run there may carry claim-local exclusions for the file
@@ -232,12 +262,16 @@ type prismaPendingComment struct {
232
262
  TopLevel bool
233
263
  }
234
264
 
235
- // prismaCommentRuns groups pending comment lines into one run per form.
265
+ // prismaCommentRuns turns the pending comment lines above one declaration into
266
+ // runs.
236
267
  //
237
- // Only the doc-comment run can carry a citation, so the other two are grouped
238
- // solely to be reported. Keeping the doc run's body line-aligned is what lets a
239
- // tag inside it be reported at the line it was written on: a line the run
240
- // skipped becomes an empty line rather than disappearing.
268
+ // Every `///` and `/* */` line that does not share its line with code is joined
269
+ // into the one documentation run, and that run alone can carry a citation. Every
270
+ // other line, a `//` comment or a comment trailing code, becomes a run of its
271
+ // own, and exists solely to be reported. Keeping the documentation run's body
272
+ // line-aligned is what lets a tag inside it be reported at the line it was
273
+ // written on: a line the run skipped becomes an empty line rather than
274
+ // disappearing.
241
275
  func prismaCommentRuns(
242
276
  source string,
243
277
  pending []prismaPendingComment,
@@ -258,11 +292,12 @@ func prismaCommentRuns(
258
292
  continue
259
293
  }
260
294
  runs = append(runs, prismaCommentRun{
261
- Form: comment.Form,
262
- Path: source,
263
- Line: comment.Line,
264
- Body: comment.Text,
265
- Key: "",
295
+ Form: comment.Form,
296
+ Path: source,
297
+ Line: comment.Line,
298
+ Body: comment.Text,
299
+ Key: "",
300
+ Trailing: comment.Trailing,
266
301
  })
267
302
  }
268
303
  if len(documenting) != 0 {
@@ -294,8 +329,8 @@ func prismaCommentRuns(
294
329
  // String contents are dropped from the code rather than kept, because a brace
295
330
  // inside one must not open or close a block — `@default("}")` is a legal field
296
331
  // that a naive brace count reads as the end of its model. The comment text is
297
- // returned rather than discarded, because two of the three forms cannot carry a
298
- // citation and this rule reports one written there instead of ignoring it.
332
+ // returned rather than discarded, because a `//` comment cannot carry a citation
333
+ // and this rule reports one written there instead of ignoring it.
299
334
  func prismaPartsOf(
300
335
  line string,
301
336
  commented bool,
@@ -345,7 +380,9 @@ func prismaPartsOf(
345
380
  } else if form == "" {
346
381
  form = prismaLineComment
347
382
  }
348
- comment.WriteString(strings.TrimSpace(string(runes[rest:])))
383
+ // One ordinary padding space follows the documentation prefix; further
384
+ // indentation belongs to the body and decides whether a fence is literal.
385
+ comment.WriteString(strings.TrimPrefix(string(runes[rest:]), " "))
349
386
  break
350
387
  }
351
388
  if char == '/' && index+1 < len(runes) && runes[index+1] == '*' {
@@ -358,7 +395,7 @@ func prismaPartsOf(
358
395
  }
359
396
  code.WriteRune(char)
360
397
  }
361
- return code.String(), strings.TrimSpace(comment.String()), form, commented
398
+ return code.String(), strings.TrimRight(comment.String(), " \t\r"), form, commented
362
399
  }
363
400
 
364
401
  // prismaBlockHead reads `model <Name>` from a block opener.
package/native/review.go CHANGED
@@ -18,7 +18,8 @@ import (
18
18
  // second one is never put.
19
19
  //
20
20
  // The rule puts it. Every `@evidence` and `@evidenceExclude` on a public
21
- // identity must be answered by an `@evidenceReview` naming the same target, so a
21
+ // identity must be answered by `@evidenceReview` or `@evidenceExcludeReview`,
22
+ // respectively, naming the same target, so a
22
23
  // citation cannot be produced without a separately addressed statement written
23
24
  // as its own act.
24
25
  //
@@ -145,12 +146,6 @@ func judgeReviewedHost(ctx *rule.Context, host documentedHost) {
145
146
  }
146
147
  }
147
148
 
148
- // acknowledgementQuestion is what a review of this tag has to answer.
149
- //
150
- // The two are not the same question and the diagnostic says which one is open. A
151
- // citation is verified by checking that this declaration does what the cited unit
152
- // describes. An exclusion is verified by checking that the unit genuinely does not
153
- // apply here, which no reading of this declaration can establish on its own.
154
149
  // reviewExample shows the shape of a check that answers this tag's question.
155
150
  //
156
151
  // The two examples differ because the two checks do. A citation is answered by
@@ -164,6 +159,12 @@ func reviewExample(tag tagKind) string {
164
159
  return "A review of a citation names what you read or ran, as in 'read the section's three rules and ran the checkout test'."
165
160
  }
166
161
 
162
+ // acknowledgementQuestion is what a review of this tag has to answer.
163
+ //
164
+ // The two are not the same question and the diagnostic says which one is open. A
165
+ // citation is verified by checking that this declaration does what the cited unit
166
+ // describes. An exclusion is verified by checking that the unit genuinely does not
167
+ // apply here, which no reading of this declaration can establish on its own.
167
168
  func acknowledgementQuestion(tag tagKind) string {
168
169
  if tag == tagExclude {
169
170
  return "The exclusion states that this claim does not cover that target."
@@ -2,7 +2,6 @@ package evidence
2
2
 
3
3
  import (
4
4
  "io/fs"
5
- "os"
6
5
  "path/filepath"
7
6
  "sort"
8
7
  "strings"
@@ -14,7 +13,7 @@ func materializeRootedTypeScriptReference(claim claimSpec, reference referenceSp
14
13
  state := referenceState{Spec: reference, Healthy: false, UnitsByScope: map[string][]*evidenceUnit{}}
15
14
  context := claimLabel(claim) + " " + referenceLabel(reference)
16
15
  base := reference.Base
17
- if info, err := os.Stat(base.Absolute); err != nil || !info.IsDir() {
16
+ if info, err := loader.inputs.Stat(base.Absolute); err != nil || !info.IsDir() {
18
17
  cause := "the path names a file"
19
18
  if err != nil {
20
19
  cause = causeText(err)
@@ -25,13 +24,13 @@ func materializeRootedTypeScriptReference(claim claimSpec, reference referenceSp
25
24
  if !resolved {
26
25
  return state, []string{unresolvedBaseProblem(base, artifactTypeScript)}
27
26
  }
28
- from, resolved = physicalTypeScriptPath(from)
27
+ from, resolved = physicalTypeScriptPath(from, loader.inputs)
29
28
  if !resolved {
30
29
  return state, []string{unresolvedBaseProblem(base, artifactTypeScript)}
31
30
  }
32
31
  entries := map[string]bool{}
33
32
  problems := []string{}
34
- err := filepath.WalkDir(from, func(current string, entry fs.DirEntry, walkErr error) error {
33
+ err := loader.inputs.WalkDir(from, func(current string, entry fs.DirEntry, walkErr error) error {
35
34
  if walkErr != nil {
36
35
  return walkErr
37
36
  }
package/native/swagger.go CHANGED
@@ -8,6 +8,7 @@ import (
8
8
  "net/url"
9
9
  "os"
10
10
  "os/exec"
11
+ "regexp"
11
12
  "sort"
12
13
  "strings"
13
14
  "time"
@@ -65,7 +66,8 @@ type swaggerNormalizationResult struct {
65
66
  // document's bytes — an entry that answers a later cycle with the wrong
66
67
  // document, forever.
67
68
  //
68
- // It is empty for a remote source, which never participates in reuse.
69
+ // It is empty for a remote source, whose successful result is reused by URL
70
+ // rather than by content for the process lifetime.
69
71
  type swaggerDocumentInventory struct {
70
72
  Source string `json:"source"`
71
73
  Operations []swaggerOperation `json:"operations"`
@@ -114,16 +116,37 @@ func loadSwaggerInventories(
114
116
  // two-hundred-operation one pay nearly the same. A resident host repeats
115
117
  // this every cycle, so an unchanged document is re-normalized on every
116
118
  // TypeScript keystroke that triggers a rebuild.
117
- digests := swaggerContentDigests(root, sources)
119
+ config.inputs.Unavailable()
120
+ digests := swaggerContentDigests(root, sources, config.inputs)
121
+ trace := newEvidenceBridgeTrace("swagger")
122
+ if trace != nil {
123
+ trace.nativeLookup = true
124
+ trace.record("bridge-lookup", os.Getpid(), map[string]any{
125
+ "bridge": "swagger", "root": root, "sources": sources,
126
+ "nativeDigests": digests, "nativeLookup": true,
127
+ })
128
+ }
118
129
  pending := []string{}
119
130
  problems := graphDiagnostics{}
120
131
  severity := artifactSeverity(config, artifactSwagger)
121
132
  for _, source := range sources {
122
133
  outcome, hit := lookupSwaggerDocument(source, digests[source])
123
134
  if !hit {
135
+ if trace != nil {
136
+ _, present := digests[source]
137
+ trace.record("bridge-cache-miss", os.Getpid(), map[string]any{
138
+ "bridge": "swagger", "source": source, "nativeDigest": digests[source], "nativeDigestPresent": present,
139
+ })
140
+ }
124
141
  pending = append(pending, source)
125
142
  continue
126
143
  }
144
+ if trace != nil {
145
+ _, present := digests[source]
146
+ trace.record("bridge-cache-hit", os.Getpid(), map[string]any{
147
+ "bridge": "swagger", "source": source, "nativeDigest": digests[source], "nativeDigestPresent": present,
148
+ })
149
+ }
127
150
  problems = problems.add(
128
151
  swaggerSeverity(config, source),
129
152
  swaggerUnitsFromOutcome(source, inventories[source], outcome)...,
@@ -133,9 +156,13 @@ func loadSwaggerInventories(
133
156
  return inventories, problems
134
157
  }
135
158
 
136
- result, err := normalizeSwaggerSources(root, pending)
159
+ result, err := normalizeSwaggerSources(root, pending, trace)
137
160
  if err != nil {
138
- message := "Evidence graph could not run its Swagger normalizer: " + causeText(err) + ". Swagger references require Node.js and the installed @typia/interface, @typia/utils, and yaml dependencies."
161
+ reason := causeText(err)
162
+ for _, source := range pending {
163
+ reason = redactSwaggerError(source, reason)
164
+ }
165
+ message := "Evidence graph could not run its Swagger normalizer: " + reason + ". Swagger references require Node.js and the installed @typia/interface, @typia/utils, and yaml dependencies."
139
166
  for _, source := range pending {
140
167
  inventories[source].LoadFailed = true
141
168
  inventories[source].Problems = append(
@@ -229,22 +256,41 @@ func configuredSwaggerSources(config graphConfig) []string {
229
256
  return sources
230
257
  }
231
258
 
259
+ // normalizeSwaggerSources retains the real Node request/Run/unmarshal boundary.
260
+ // Optional private trace context pairs the caller's existing local digest map;
261
+ // direct calls remain unpaired and do not invent native lookup values. Actual
262
+ // pending source order, wire bytes, returned outcomes and cache keys are kept.
232
263
  func normalizeSwaggerSources(
233
264
  root string,
234
265
  sources []string,
266
+ traces ...*evidenceBridgeTrace,
235
267
  ) (swaggerNormalizationResult, error) {
268
+ var trace *evidenceBridgeTrace
269
+ if len(traces) != 0 {
270
+ trace = traces[0]
271
+ } else {
272
+ trace = newEvidenceBridgeTrace("swagger")
273
+ }
236
274
  request, err := json.Marshal(swaggerNormalizationRequest{
237
275
  Root: root,
238
276
  Sources: sources,
239
277
  })
240
278
  if err != nil {
279
+ trace.preparationFailure(err)
241
280
  return swaggerNormalizationResult{}, err
242
281
  }
282
+ if trace != nil {
283
+ trace.record("bridge-request", os.Getpid(), map[string]any{
284
+ "bridge": "swagger", "root": root, "sources": sources, "nativeLookup": trace.nativeLookup,
285
+ })
286
+ }
243
287
  node := os.Getenv("TTSC_NODE_BINARY")
244
288
  if node == "" {
245
289
  node, err = exec.LookPath("node")
246
290
  if err != nil {
247
- return swaggerNormalizationResult{}, errors.New("Node.js executable was not found")
291
+ failure := errors.New("Node.js executable was not found")
292
+ trace.preparationFailure(failure)
293
+ return swaggerNormalizationResult{}, failure
248
294
  }
249
295
  }
250
296
  ctx, cancel := context.WithTimeout(context.Background(), swaggerBridgeTimeout)
@@ -256,20 +302,43 @@ func normalizeSwaggerSources(
256
302
  stderr := &limitedBuffer{Limit: swaggerBridgeErrorLimit}
257
303
  command.Stdout = stdout
258
304
  command.Stderr = stderr
259
- if err := command.Run(); err != nil {
260
- if ctx.Err() == context.DeadlineExceeded {
305
+ start := trace.attempt(command)
306
+ runErr := command.Run()
307
+ deadlineExceeded := false
308
+ if runErr != nil {
309
+ deadlineExceeded = ctx.Err() == context.DeadlineExceeded
310
+ }
311
+ end := time.Time{}
312
+ if trace != nil {
313
+ end = time.Now().UTC()
314
+ }
315
+ if runErr != nil {
316
+ trace.result(command, stdout, stderr, start, end, runErr, "not-attempted", nil, nil, nil)
317
+ if deadlineExceeded {
261
318
  return swaggerNormalizationResult{}, errors.New("Swagger normalizer exceeded its 60 second timeout")
262
319
  }
263
320
  detail := strings.TrimSpace(stderr.String())
264
321
  if detail == "" {
265
- detail = err.Error()
322
+ detail = runErr.Error()
266
323
  }
267
324
  return swaggerNormalizationResult{}, errors.New(detail)
268
325
  }
269
326
  var result swaggerNormalizationResult
270
327
  if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
328
+ trace.result(command, stdout, stderr, start, end, nil, "failed", err, nil, nil)
271
329
  return swaggerNormalizationResult{}, errors.New("Swagger normalizer returned invalid JSON: " + err.Error())
272
330
  }
331
+ if trace != nil {
332
+ documents := make([]string, 0, len(result.Documents))
333
+ problems := make([]string, 0, len(result.Problems))
334
+ for _, document := range result.Documents {
335
+ documents = append(documents, document.Source)
336
+ }
337
+ for _, problem := range result.Problems {
338
+ problems = append(problems, problem.Source)
339
+ }
340
+ trace.result(command, stdout, stderr, start, end, nil, "succeeded", nil, documents, problems)
341
+ }
273
342
  return result, nil
274
343
  }
275
344
 
@@ -315,27 +384,66 @@ func isRemoteSwaggerSource(source string) bool {
315
384
  return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https")
316
385
  }
317
386
 
387
+ var swaggerErrorURL = regexp.MustCompile(`(?i)https?://[^\s"'<>]+`)
388
+
389
+ // displaySwaggerSource presents a URL without userinfo, query or fragment.
390
+ // Lexical boundaries also cover malformed URLs rejected by net/url. Local
391
+ // filesystem paths retain their spelling; this value never enters cache or
392
+ // transport identity. A malformed HTTP spelling without an authority has no
393
+ // reliably separable host/path and is shown only with its scheme.
318
394
  func displaySwaggerSource(source string) string {
319
- parsed, err := url.Parse(source)
320
- if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
395
+ lower := strings.ToLower(source)
396
+ if !strings.Contains(source, "://") && !strings.HasPrefix(lower, "http:") && !strings.HasPrefix(lower, "https:") {
321
397
  return source
322
398
  }
323
- if parsed.User != nil {
324
- parsed.User = url.User("***")
399
+ suffix := strings.IndexAny(source, "?#")
400
+ base := source
401
+ if suffix >= 0 {
402
+ base = source[:suffix]
403
+ }
404
+ scheme := strings.Index(base, "://")
405
+ if scheme < 0 {
406
+ return source[:strings.Index(source, ":")+1] + "[redacted]"
325
407
  }
326
- if parsed.RawQuery != "" {
327
- parsed.RawQuery = "<redacted>"
408
+ start := scheme + 3
409
+ end := len(base)
410
+ if slash := strings.Index(base[start:], "/"); slash >= 0 {
411
+ end = start + slash
412
+ }
413
+ if user := strings.LastIndex(base[start:end], "@"); user >= 0 {
414
+ base = base[:start] + "***@" + base[start+user+1:]
415
+ } else if _, err := url.Parse(base[:end]); err != nil {
416
+ // A malformed authority can be truncated userinfo, not a separable host.
417
+ base = base[:start] + "[redacted]" + base[end:]
418
+ }
419
+ if suffix >= 0 {
420
+ base += "?[redacted]"
421
+ }
422
+ return base
423
+ }
424
+
425
+ // redactSwaggerError sanitizes remote diagnostic presentation, including every
426
+ // repeated or foreign URL reflected by an external error. Exact source lookup
427
+ // and cache state retain their raw keys. Local filesystem causes stay intact.
428
+ func redactSwaggerError(source string, message string) string {
429
+ if displaySwaggerSource(source) == source && !isRemoteSwaggerSource(source) {
430
+ return message
328
431
  }
329
- return parsed.String()
432
+ message = strings.ReplaceAll(message, source, displaySwaggerSource(source))
433
+ return swaggerErrorURL.ReplaceAllStringFunc(message, displaySwaggerSource)
330
434
  }
331
435
 
332
436
  type limitedBuffer struct {
333
437
  bytes.Buffer
334
438
  Limit int
439
+ // Exceeded observes the existing failure boundary without changing its bytes
440
+ // or return error. A captured prefix cannot be a complete wire observation.
441
+ Exceeded bool
335
442
  }
336
443
 
337
444
  func (buffer *limitedBuffer) Write(content []byte) (int, error) {
338
445
  if buffer.Len()+len(content) > buffer.Limit {
446
+ buffer.Exceeded = true
339
447
  remaining := buffer.Limit - buffer.Len()
340
448
  if remaining > 0 {
341
449
  _, _ = buffer.Buffer.Write(content[:remaining])
@@ -3,7 +3,6 @@ package evidence
3
3
  import (
4
4
  "crypto/sha256"
5
5
  "encoding/hex"
6
- "os"
7
6
  "strings"
8
7
  "sync"
9
8
  )
@@ -33,7 +32,9 @@ const swaggerCacheLimit = 64
33
32
  var swaggerDocuments = newSwaggerCache()
34
33
 
35
34
  // swaggerRemoteDocuments remembers a URL's operations for the process lifetime,
36
- // keyed by the address rather than by content.
35
+ // keyed by the address rather than by content, within the same entry bound as
36
+ // the local cache: a session reading more distinct URLs than the bound fetches
37
+ // the oldest again.
37
38
  //
38
39
  // A remote document has no key without fetching it, so it cannot be revalidated
39
40
  // the way a local file is. The choice is therefore not "cache or revalidate" but
@@ -65,7 +66,7 @@ var swaggerRemoteDocuments = newSwaggerCache()
65
66
  // non-empty. Inferring rejection from the message would turn a reason-less
66
67
  // failure into zero operations and no diagnostic — a rejected document that
67
68
  // reads exactly like an empty but passing one, which is the shape
68
- // `test_evidence_graph_reports_swagger_source_failures` exists to forbid.
69
+ // `TestSwaggerReportsARejectionThatCarriesNoReason` exists to forbid.
69
70
  type swaggerDocumentOutcome struct {
70
71
  Operations []swaggerOperation
71
72
  Rejected bool
@@ -126,18 +127,20 @@ func (cache *swaggerCache) store(digest string, outcome swaggerDocumentOutcome)
126
127
  //
127
128
  // The digest is the whole cache key, which is what makes staleness structural
128
129
  // rather than improbable: identical bytes normalize to identical operations, so
129
- // a hit can only return what the current file means. An edit, a truncation, or
130
- // a same-length replacement all change the digest and miss. A missing or
131
- // unreadable file yields no digest and is normalized as before, which keeps the
132
- // normalizer's own diagnostic for it.
130
+ // a hit returns what the current file means to the normalizer that first read
131
+ // it. The key names no project root and no normalizer installation. An edit, a
132
+ // truncation, or a same-length replacement all change the digest and miss. A
133
+ // missing or unreadable file yields no digest and is normalized as before, which
134
+ // keeps the normalizer's own diagnostic for it.
133
135
  //
134
136
  // An HTTP(S) source never participates. A URL has no validator without a
135
137
  // fetch, and the fetch is most of what the normalizer costs, so a remote
136
138
  // document cannot be shown unchanged without paying the price of finding out.
137
- func swaggerContentDigests(root string, sources []string) map[string]string {
139
+ func swaggerContentDigests(root string, sources []string, readers ...evidenceInputReader) map[string]string {
140
+ inputs := inputReader(readers)
138
141
  digests := map[string]string{}
139
142
  for _, source := range sources {
140
- digest := swaggerContentDigest(root, source)
143
+ digest := swaggerContentDigest(root, source, inputs)
141
144
  if digest != "" {
142
145
  digests[source] = digest
143
146
  }
@@ -145,11 +148,12 @@ func swaggerContentDigests(root string, sources []string) map[string]string {
145
148
  return digests
146
149
  }
147
150
 
148
- func swaggerContentDigest(root string, source string) string {
151
+ func swaggerContentDigest(root string, source string, readers ...evidenceInputReader) string {
152
+ inputs := inputReader(readers)
149
153
  if isRemoteSwaggerSource(source) {
150
154
  return ""
151
155
  }
152
- content, err := os.ReadFile(swaggerSourcePath(root, source))
156
+ content, err := inputs.ReadFile(swaggerSourcePath(root, source))
153
157
  if err != nil {
154
158
  return ""
155
159
  }
@@ -262,7 +266,7 @@ func swaggerUnitsFromOutcome(
262
266
  // that trails off after a colon reads like a formatting bug rather than a
263
267
  // broken document.
264
268
  func swaggerNormalizationFailure(source string, message string) string {
265
- reason := causeReason(strings.TrimSpace(message))
269
+ reason := causeReason(strings.TrimSpace(redactSwaggerError(source, message)))
266
270
  if reason == "" {
267
271
  reason = "the normalizer reported no reason"
268
272
  }
package/native/todo.go CHANGED
@@ -86,6 +86,11 @@ func todoMessage(entry string) string {
86
86
  // '@todo' shares a block with '@param' or '@evidence' without swallowing them.
87
87
  // The tag name matches case-insensitively, because '@TODO' is the same promise
88
88
  // shouted.
89
+ //
90
+ // A fenced example is not read for tags, as it is not by the declaration parser:
91
+ // a '@todo' shown inside one is documentation of the tag rather than a debt. A
92
+ // fence inside an entry's text stays part of it, so the example is not cut out
93
+ // of the debt it follows.
89
94
  func todoEntries(comment string) []string {
90
95
  comment = strings.TrimSpace(comment)
91
96
  comment = strings.TrimPrefix(comment, "/**")
@@ -100,9 +105,16 @@ func todoEntries(comment string) []string {
100
105
  entries = append(entries, strings.TrimSpace(strings.Join(pending, " ")))
101
106
  pending = nil
102
107
  }
108
+ fence := commentFence{}
103
109
  for _, rawLine := range strings.Split(comment, "\n") {
104
110
  line := strings.TrimSpace(rawLine)
105
111
  line = strings.TrimSpace(strings.TrimPrefix(line, "*"))
112
+ if fence.consume(line) {
113
+ if pending != nil && line != "" {
114
+ pending = append(pending, line)
115
+ }
116
+ continue
117
+ }
106
118
  if remainder, opened := todoLine(line); opened {
107
119
  flush()
108
120
  pending = []string{remainder}