@ttsc/evidence 0.25.0 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/native/exports.go CHANGED
@@ -25,6 +25,16 @@ type moduleExport struct {
25
25
  // Namespace marks `export * as ns from`, which nests the target's whole
26
26
  // surface one segment deeper instead of flattening it.
27
27
  Namespace bool
28
+ // TypeOnly marks an export that carries only the type side of what it names,
29
+ // written either as `export type { X }` or as `export { type X }`.
30
+ //
31
+ // A local export list already reaches the collector's own projection guard,
32
+ // so this field exists for the half that cannot: a re-export naming another
33
+ // module. Without it the mark stopped at the module boundary, and a barrel
34
+ // saying `export type { Sale } from "./sale.js"` published every class member
35
+ // the declaring file holds, which is the opposite of both what the author
36
+ // wrote and what the same intent written locally does.
37
+ TypeOnly bool
28
38
  }
29
39
 
30
40
  // collectModuleExports reads a file's public surface as importers see it.
@@ -94,7 +104,12 @@ func exportDeclarationEntries(
94
104
  return nil
95
105
  }
96
106
  if declaration.ExportClause == nil {
97
- return []moduleExport{{Specifier: specifier}}
107
+ // `export * from` and `export type * from`. The star form has no clause to
108
+ // carry a per-name mark, so the declaration's own is the whole answer.
109
+ return []moduleExport{{
110
+ Specifier: specifier,
111
+ TypeOnly: declaration.IsTypeOnly,
112
+ }}
98
113
  }
99
114
  switch declaration.ExportClause.Kind {
100
115
  case shimast.KindNamespaceExport:
@@ -106,6 +121,7 @@ func exportDeclarationEntries(
106
121
  Public: name,
107
122
  Specifier: specifier,
108
123
  Namespace: true,
124
+ TypeOnly: declaration.IsTypeOnly,
109
125
  }}
110
126
  case shimast.KindNamedExports:
111
127
  named := declaration.ExportClause.AsNamedExports()
@@ -133,6 +149,10 @@ func exportDeclarationEntries(
133
149
  Public: public,
134
150
  Specifier: specifier,
135
151
  Imported: imported,
152
+ // Either spelling marks it. The declaration carries the mark for
153
+ // `export type { A, B } from`, the specifier for
154
+ // `export { type A, B } from`, and the second is per name.
155
+ TypeOnly: declaration.IsTypeOnly || exportSpecifier.IsTypeOnly,
136
156
  })
137
157
  }
138
158
  return entries
@@ -192,6 +212,11 @@ type reachedSymbol struct {
192
212
  // the name it exposes rather than the binding it wrote. `export { a as b }`
193
213
  // declares one unit called `b`, so matching on `a` would find nothing.
194
214
  Local string
215
+ // TypeOnly is true when some edge on the path from the entry to this symbol
216
+ // carried only its type side. It travels rather than being read at the end,
217
+ // because a barrel re-exported type-only withholds value-space from
218
+ // everything below it however many hops away that is.
219
+ TypeOnly bool
195
220
  }
196
221
 
197
222
  // traverseEntryExports walks a module's export graph and reports every public
@@ -206,6 +231,7 @@ func traverseEntryExports(
206
231
  entry string,
207
232
  prefix []string,
208
233
  visited map[string]bool,
234
+ typeOnly bool,
209
235
  ) []reachedSymbol {
210
236
  if visited[entry] {
211
237
  return nil
@@ -228,12 +254,31 @@ func traverseEntryExports(
228
254
  return surface
229
255
  }
230
256
  surface := map[string]reachedSymbol{}
231
- for _, nested := range traverseEntryExports(loader, target, nil, visited) {
257
+ for _, nested := range traverseEntryExports(loader, target, nil, visited, false) {
232
258
  if len(nested.Address) != 1 {
233
259
  continue
234
260
  }
235
- if _, taken := surface[nested.Address[0]]; !taken {
261
+ existing, taken := surface[nested.Address[0]]
262
+ // First wins, because two paths to one declaration name the same unit and
263
+ // the entry has to keep describing a path that exists.
264
+ //
265
+ // The type-only mark is unioned, and only between paths to the same
266
+ // declaration. It differs per path while the population is the union of
267
+ // what the paths reach, so a value path has to clear a type-only one
268
+ // rather than lose to declaration order. Doing that unconditionally is
269
+ // two different mistakes: replacing the entry swaps `Path` and `Local`,
270
+ // and clearing the mark alone leaves `Path` from one entry with the mark
271
+ // from another, which is a combination no path produced. Two entries
272
+ // under one name are not always one declaration, since an explicit named
273
+ // re-export shadows a star, and either mistake publishes the value
274
+ // members of a declaration this module reaches type-only.
275
+ switch {
276
+ case !taken:
236
277
  surface[nested.Address[0]] = nested
278
+ case existing.TypeOnly && !nested.TypeOnly &&
279
+ existing.Path == nested.Path && existing.Local == nested.Local:
280
+ existing.TypeOnly = false
281
+ surface[nested.Address[0]] = existing
237
282
  }
238
283
  }
239
284
  surfaces[target] = surface
@@ -245,9 +290,10 @@ func traverseEntryExports(
245
290
  // exposes it as. `export { local as renamed }` is one unit named
246
291
  // `renamed`, so the local binding never identifies it.
247
292
  reached = append(reached, reachedSymbol{
248
- Address: append(append([]string{}, prefix...), export.Public),
249
- Path: entry,
250
- Local: export.Public,
293
+ Address: append(append([]string{}, prefix...), export.Public),
294
+ Path: entry,
295
+ Local: export.Public,
296
+ TypeOnly: typeOnly,
251
297
  })
252
298
  continue
253
299
  }
@@ -262,6 +308,7 @@ func traverseEntryExports(
262
308
  target,
263
309
  append(append([]string{}, prefix...), export.Public),
264
310
  visited,
311
+ typeOnly || export.TypeOnly,
265
312
  )...)
266
313
  case export.Imported == "":
267
314
  reached = append(reached, traverseEntryExports(
@@ -269,6 +316,7 @@ func traverseEntryExports(
269
316
  target,
270
317
  prefix,
271
318
  visited,
319
+ typeOnly || export.TypeOnly,
272
320
  )...)
273
321
  default:
274
322
  nested, found := surfaceOf(target)[export.Imported]
@@ -279,6 +327,9 @@ func traverseEntryExports(
279
327
  Address: append(append([]string{}, prefix...), export.Public),
280
328
  Path: nested.Path,
281
329
  Local: nested.Local,
330
+ // The edge into this module and every edge the target itself walked
331
+ // both count, so a type-only hop anywhere on the path withholds.
332
+ TypeOnly: typeOnly || export.TypeOnly || nested.TypeOnly,
282
333
  })
283
334
  }
284
335
  }
@@ -312,7 +363,7 @@ func materializeEntryUnits(
312
363
  published := []publishedAddress{}
313
364
  candidates := newOwnedUnitIndex(loader)
314
365
  for _, entry := range entries {
315
- for _, symbol := range traverseEntryExports(loader, entry, nil, map[string]bool{}) {
366
+ for _, symbol := range traverseEntryExports(loader, entry, nil, map[string]bool{}, false) {
316
367
  if symbol.Local == "" {
317
368
  continue
318
369
  }
@@ -321,6 +372,12 @@ func materializeEntryUnits(
321
372
  if !owned {
322
373
  continue
323
374
  }
375
+ // A type-only edge exposes no value, so nothing reached only through
376
+ // one comes with it. The name itself still arrives, because a type is
377
+ // exactly what such an export carries.
378
+ if symbol.TypeOnly && unit.ValueSpace && !unit.TypeSpace {
379
+ continue
380
+ }
324
381
  address := append(append([]string{}, symbol.Address...), suffix...)
325
382
  target := strings.Join(address, ".")
326
383
  current := byID[unit.ID]
@@ -0,0 +1,351 @@
1
+ package evidence
2
+
3
+ import (
4
+ "crypto/sha256"
5
+ "encoding/hex"
6
+ "sort"
7
+ "strings"
8
+
9
+ shimast "github.com/microsoft/typescript-go/shim/ast"
10
+ )
11
+
12
+ // normalizeFingerprintText puts content into the one form every platform agrees
13
+ // on before it is hashed.
14
+ //
15
+ // Line endings collapse and trailing whitespace per line is dropped, because a
16
+ // consumer's `core.autocrlf` and `.gitattributes` are outside this repository's
17
+ // control. A digest over raw bytes would expire every review on a CRLF checkout
18
+ // and none on an LF checkout of the same commit, which is a stale report whose
19
+ // repair no author can perform. A reformat that changes no content must not
20
+ // expire a review either, and leading whitespace is kept because in Markdown and
21
+ // TypeScript alike it can carry structure.
22
+ func normalizeFingerprintText(text string) string {
23
+ text = strings.ReplaceAll(text, "\r\n", "\n")
24
+ text = strings.ReplaceAll(text, "\r", "\n")
25
+ lines := strings.Split(text, "\n")
26
+ for index, line := range lines {
27
+ lines[index] = strings.TrimRight(line, " \t")
28
+ }
29
+ // Trailing blank lines say nothing about content and appear or vanish with an
30
+ // editor's final-newline setting.
31
+ for len(lines) != 0 && lines[len(lines)-1] == "" {
32
+ lines = lines[:len(lines)-1]
33
+ }
34
+ // Leading blank lines go for a sharper reason. A TypeScript node's position is
35
+ // its *full* start, meaning the end of the previous token, so an undocumented
36
+ // declaration's span begins with whatever blank lines and `//` comments sit
37
+ // above it. Keeping them made inserting one blank line elsewhere in the file
38
+ // expire a review, and inconsistently: the same edit above a *documented*
39
+ // declaration is neutral, because the whole leading run through `*/` is
40
+ // excluded as a tag position. Dropping them makes both cases agree and honors
41
+ // the rule that a reformat which changes no content expires nothing.
42
+ return strings.Join(lines, "\n")
43
+ }
44
+
45
+ // withoutLeadingTrivia drops the leading run a TypeScript span carries but does
46
+ // not own.
47
+ //
48
+ // A node's position is its full start, so an undocumented declaration's text
49
+ // begins at the previous token and arrives with the blank lines and `//` comments
50
+ // above it. Those are not content of the declaration: keeping them made inserting
51
+ // a blank line elsewhere expire a review, and inconsistently, because above a
52
+ // *documented* declaration the whole leading run through the block is already
53
+ // excluded as a tag position.
54
+ //
55
+ // Only the leading run, and only for TypeScript. An interior comment is part of
56
+ // what the author wrote inside the body, and in Markdown a line opening with `//`
57
+ // is ordinary prose that must keep counting.
58
+ func withoutLeadingTrivia(text string) string {
59
+ lines := strings.Split(text, "\n")
60
+ for len(lines) != 0 {
61
+ first := strings.TrimSpace(lines[0])
62
+ if first != "" && !strings.HasPrefix(first, "//") {
63
+ break
64
+ }
65
+ lines = lines[1:]
66
+ }
67
+ return strings.Join(lines, "\n")
68
+ }
69
+
70
+ // contentDigest is the digest of one unit's own content.
71
+ func contentDigest(text string) string {
72
+ sum := sha256.Sum256([]byte(normalizeFingerprintText(text)))
73
+ return hex.EncodeToString(sum[:])
74
+ }
75
+
76
+ // scopeIndex answers what a citation of one unit has to name, over one
77
+ // reference's whole materialized population.
78
+ //
79
+ // The scope of a citation is the unit plus every structural descendant, and it is
80
+ // deliberately **not** the reference's covered set. `UnitsByScope` and `Units` are
81
+ // both narrowed by that reference's `symbol` selector, while one `@evidence` tag
82
+ // may acknowledge several references and carries exactly one fingerprint token.
83
+ // Composing from a narrowed set made the value a function of the reference: a
84
+ // selector that skipped the descendants left them out of the digest, so the
85
+ // review never expired, and two references over one scope under different
86
+ // selectors demanded two different values from that single token with no value
87
+ // able to satisfy both. The population is passed instead, which is why this type
88
+ // exists rather than a function over `Units`.
89
+ //
90
+ // So the fingerprint is a property of the address a citation names rather than of
91
+ // what the citation discharges. A reference confining acknowledgement to the
92
+ // named unit does not narrow this: the citation still names the scope, and
93
+ // re-reviewing when the subtree moves is conservative rather than wrong.
94
+ //
95
+ // Descendants are found through the stored parent identity, never through a
96
+ // shared address prefix. A literal dot may sit inside a TypeScript name, so
97
+ // treating a common prefix as ownership would make an unrelated same-name
98
+ // declaration an ancestor.
99
+ //
100
+ // A withdrawn descendant contributes its identity, so adding `@internal` to a
101
+ // member moves the composite: the tag's own block is excluded as a tag position,
102
+ // which would otherwise make a withdrawal invisible.
103
+ //
104
+ // Its content is **not** separable from its ancestor's, and an earlier version of
105
+ // this comment claimed otherwise. A TypeScript unit's digest is its whole
106
+ // declaration text, so a withdrawn member's body is already inside the enclosing
107
+ // type's digest and no substitution here can take it out. Churn behind
108
+ // `@internal` therefore does expire a review of the enclosing type. That is
109
+ // conservative noise rather than a wrong answer, and the honest trade: removing it
110
+ // means excluding every child unit's span from its ancestor's text, which is a
111
+ // second exclusion mechanism layered on the tag-position one.
112
+ //
113
+ // The index is built once per reference and the answers are memoized, because
114
+ // building it per citation is quadratic and the cost is not theoretical: it spans
115
+ // the whole population, so a claim with a citation per selected unit would walk
116
+ // every unit once per citation. On a requirements set of a few thousand headings
117
+ // that is millions of map inserts per Program cycle, paid again on every watch
118
+ // rebuild, for a value that is identical each time.
119
+ type scopeIndex struct {
120
+ byID map[string]*evidenceUnit
121
+ children map[string][]*evidenceUnit
122
+ cache map[string]string
123
+ }
124
+
125
+ func newScopeIndex(units []*evidenceUnit) *scopeIndex {
126
+ index := &scopeIndex{
127
+ byID: make(map[string]*evidenceUnit, len(units)),
128
+ children: map[string][]*evidenceUnit{},
129
+ cache: map[string]string{},
130
+ }
131
+ for _, unit := range units {
132
+ if unit == nil || index.byID[unit.ID] != nil {
133
+ continue
134
+ }
135
+ index.byID[unit.ID] = unit
136
+ if unit.ParentID != "" {
137
+ index.children[unit.ParentID] = append(index.children[unit.ParentID], unit)
138
+ }
139
+ }
140
+ return index
141
+ }
142
+
143
+ // fingerprint answers for one cited scope, remembering the answer.
144
+ //
145
+ // Several citations of one scope are ordinary — a reference may be acknowledged
146
+ // by many hosts — so the memo matters as much as the shared index.
147
+ func (index *scopeIndex) fingerprint(rootID string) string {
148
+ if index == nil || rootID == "" {
149
+ return ""
150
+ }
151
+ if remembered, seen := index.cache[rootID]; seen {
152
+ return remembered
153
+ }
154
+ computed := index.compute(rootID)
155
+ index.cache[rootID] = computed
156
+ return computed
157
+ }
158
+
159
+ func (index *scopeIndex) compute(rootID string) string {
160
+ scope := index.collect(rootID)
161
+ if len(scope) == 0 {
162
+ return ""
163
+ }
164
+ // Ordered by address rather than by walk order, so the value does not depend
165
+ // on which file the loader happened to read first, and by symbol as well
166
+ // because two same-titled Markdown headings answer to one anchor.
167
+ sort.Slice(scope, func(left int, right int) bool {
168
+ if scope[left].Target != scope[right].Target {
169
+ return scope[left].Target < scope[right].Target
170
+ }
171
+ if scope[left].Symbol != scope[right].Symbol {
172
+ return scope[left].Symbol < scope[right].Symbol
173
+ }
174
+ return scope[left].Digest < scope[right].Digest
175
+ })
176
+ composite := sha256.New()
177
+ for _, unit := range scope {
178
+ // NUL separates the fields because a target may contain any printable
179
+ // character, and a joined pair that can be re-split ambiguously lets two
180
+ // different scopes compose one digest.
181
+ composite.Write([]byte(unit.Target))
182
+ composite.Write([]byte{0})
183
+ composite.Write([]byte(unit.Symbol))
184
+ composite.Write([]byte{0})
185
+ composite.Write([]byte(scopeContribution(unit)))
186
+ composite.Write([]byte{0})
187
+ }
188
+ return presentedFingerprint(hex.EncodeToString(composite.Sum(nil)))
189
+ }
190
+
191
+ // scopeContribution is what one unit adds to its scope's composite.
192
+ //
193
+ // A withdrawn unit contributes the tag that withdrew it. That is what makes a
194
+ // withdrawal visible at all, since the tag lives in a documentation block and
195
+ // every such block is excluded as a tag position, so the enclosing declaration's
196
+ // text is unchanged by adding one.
197
+ //
198
+ // It does not make the withdrawn member's content invisible. For TypeScript that
199
+ // content sits inside its ancestor's declaration text, which is that ancestor's
200
+ // digest; see `evidenceUnit.Digest`.
201
+ func scopeContribution(unit *evidenceUnit) string {
202
+ if unit.Hidden != "" {
203
+ return "\x00withdrawn:" + unit.Hidden
204
+ }
205
+ return unit.Digest
206
+ }
207
+
208
+ // collect gathers one unit and every structural descendant.
209
+ func (index *scopeIndex) collect(rootID string) []*evidenceUnit {
210
+ children := index.children
211
+ root := index.byID[rootID]
212
+ if root == nil {
213
+ return nil
214
+ }
215
+ scope := []*evidenceUnit{}
216
+ // Iterative rather than recursive, and guarded by a visited set: parent
217
+ // identities are stored while materializing, and a cycle there would
218
+ // otherwise hang the compiler instead of reporting anything.
219
+ visited := map[string]bool{}
220
+ queue := []*evidenceUnit{root}
221
+ for len(queue) != 0 {
222
+ current := queue[0]
223
+ queue = queue[1:]
224
+ if current == nil || visited[current.ID] {
225
+ continue
226
+ }
227
+ visited[current.ID] = true
228
+ scope = append(scope, current)
229
+ queue = append(queue, children[current.ID]...)
230
+ }
231
+ return scope
232
+ }
233
+
234
+ // presentedFingerprint shortens a digest to what a tag carries.
235
+ func presentedFingerprint(digest string) string {
236
+ if len(digest) <= reviewFingerprintLength {
237
+ return digest
238
+ }
239
+ return digest[:reviewFingerprintLength]
240
+ }
241
+
242
+ // typeScriptUnitDigest hashes one identity's declarations with every
243
+ // documentation block removed.
244
+ //
245
+ // A JSDoc block is the only place a TypeScript citation or review can live, so
246
+ // removing exactly those spans is the general form of "exclude every position a
247
+ // tag can occupy". Removing only the block that happens to carry a tag would
248
+ // leave the case that breaks first: a property's own JSDoc is interior text of
249
+ // the interface containing it, so writing a review on one property would change
250
+ // the digest of every citation of the enclosing type.
251
+ //
252
+ // Blocks are collected from the whole subtree rather than from the declaration
253
+ // alone, for that same reason. An ordinary comment interior to the declaration
254
+ // stays in the digest, and that is correct: the graph reads tags only from the
255
+ // blocks a node reports, so no citation can hide in one.
256
+ //
257
+ // Leading text is the other case and reaches the digest in some forms only.
258
+ // Text outside the declaration's span was never in it, which for a variable is
259
+ // everything above its declarator, including the `export const` and any comment
260
+ // above a sibling. Text a full start swallowed is inside the span, and there
261
+ // `withoutLeadingTrivia` drops the blank lines and `//` comments while a
262
+ // documentation block is cut as a tag position. A block comment that is neither
263
+ // is hashed, so editing an ordinary `/* */` above an undocumented declaration
264
+ // expires a review of it.
265
+ //
266
+ // Every declaration of a merged identity contributes, in source order, because
267
+ // `interface I` beside `namespace I` is one unit whose content is both halves.
268
+ func typeScriptUnitDigest(
269
+ file *shimast.SourceFile,
270
+ nodes []*shimast.Node,
271
+ ) string {
272
+ if file == nil || len(nodes) == 0 {
273
+ return ""
274
+ }
275
+ content := file.Text()
276
+ ordered := make([]*shimast.Node, 0, len(nodes))
277
+ for _, node := range nodes {
278
+ if node == nil || node.Pos() < 0 || node.End() > len(content) || node.Pos() >= node.End() {
279
+ continue
280
+ }
281
+ ordered = append(ordered, node)
282
+ }
283
+ if len(ordered) == 0 {
284
+ return ""
285
+ }
286
+ sort.SliceStable(ordered, func(left int, right int) bool {
287
+ return ordered[left].Pos() < ordered[right].Pos()
288
+ })
289
+ builder := strings.Builder{}
290
+ for _, node := range ordered {
291
+ builder.WriteString(
292
+ withoutLeadingTrivia(withoutDocumentationSpans(content, node, file)),
293
+ )
294
+ builder.WriteByte(0)
295
+ }
296
+ return contentDigest(builder.String())
297
+ }
298
+
299
+ // withoutDocumentationSpans renders one declaration's text with its subtree's
300
+ // documentation blocks cut out.
301
+ func withoutDocumentationSpans(
302
+ content string,
303
+ node *shimast.Node,
304
+ file *shimast.SourceFile,
305
+ ) string {
306
+ type span struct {
307
+ start int
308
+ end int
309
+ }
310
+ excluded := []span{}
311
+ walkTypeScriptNode(node, func(current *shimast.Node) {
312
+ for _, doc := range current.JSDoc(file) {
313
+ if doc == nil {
314
+ continue
315
+ }
316
+ start, end := doc.Pos(), doc.End()
317
+ if start < node.Pos() || end > node.End() || start >= end {
318
+ continue
319
+ }
320
+ excluded = append(excluded, span{start: start, end: end})
321
+ }
322
+ })
323
+ if len(excluded) == 0 {
324
+ return content[node.Pos():node.End()]
325
+ }
326
+ sort.Slice(excluded, func(left int, right int) bool {
327
+ if excluded[left].start != excluded[right].start {
328
+ return excluded[left].start < excluded[right].start
329
+ }
330
+ return excluded[left].end < excluded[right].end
331
+ })
332
+ builder := strings.Builder{}
333
+ cursor := node.Pos()
334
+ for _, current := range excluded {
335
+ // Overlapping and duplicate spans are expected: TypeScript cascades one
336
+ // leading block onto nested nodes, so several nodes report the same
337
+ // comment. Advancing the cursor monotonically collapses them instead of
338
+ // emitting text twice or slicing backwards.
339
+ if current.end <= cursor {
340
+ continue
341
+ }
342
+ if current.start > cursor {
343
+ builder.WriteString(content[cursor:current.start])
344
+ }
345
+ cursor = current.end
346
+ }
347
+ if cursor < node.End() {
348
+ builder.WriteString(content[cursor:node.End()])
349
+ }
350
+ return builder.String()
351
+ }