@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.
@@ -7,30 +7,52 @@
7
7
  * and qualified identities keep nested contracts addressable without adding a
8
8
  * file path to every target.
9
9
  *
10
- * - `"type"` selects exported interfaces, type aliases, and namespaces. Classes
11
- * and enums do not become type units.
10
+ * - `"type"` selects exported interfaces, type aliases, classes, and namespaces.
11
+ * Enums do not become type units.
12
12
  * - `"function"` selects exported function declarations, exported `const`
13
13
  * variables initialized with an arrow function or function expression
14
14
  * (including parentheses and type-only expression wrappers), public instance
15
- * and static methods of exported classes, function-valued public class fields
16
- * (an arrow/function initializer or direct function type), and the same
17
- * callable forms exported from namespaces. Constructors and accessors are not
18
- * selected.
19
- * - `"property"` selects property signatures declared directly by exported
20
- * interfaces and object-shaped type aliases, plus exported `const`, `let`,
21
- * and `var` declarations at module or namespace scope. A `const` initialized
22
- * with an arrow or function expression remains a function; every other
23
- * variable, including a function-typed declaration or function-valued `let`
24
- * or `var`, is a property. Every exported leaf in an object or array binding
25
- * pattern is a property. Class fields and methods are not property units.
15
+ * and static methods of exported classes, method signatures of exported
16
+ * interfaces and object-shaped type aliases, every member of any of those
17
+ * three written as a callable (an arrow/function initializer or a function
18
+ * type spelled out), and the same callable forms exported from namespaces. An
19
+ * overload run is one unit. Constructors and accessors are not selected.
20
+ * - `"property"` selects every member declared directly by an exported class,
21
+ * interface, or object-shaped type alias that is not written as a callable,
22
+ * plus exported `const`, `let`, and `var` declarations at module or namespace
23
+ * scope. A `const` initialized with an arrow or function expression remains a
24
+ * function; every other variable, including a function-typed declaration or
25
+ * function-valued `let` or `var`, is a property. A member is the one place
26
+ * that rule reads the other way, on all three containers alike: it is a
27
+ * property unless it is function-valued **or** annotated with a function type
28
+ * written out, either of which makes it a function. The test is syntactic,
29
+ * because this rule reads no type checker: `charge: () => void` is a function
30
+ * and `charge: Handler` is a property even where `Handler` is an alias of the
31
+ * same type, as are a constructor type and a union. Every exported leaf in an
32
+ * object or array binding pattern is a property. An accessor, including an
33
+ * auto-accessor, is neither, and a `private` or `protected` member is not
34
+ * selected whichever syntax declared it. A constructor parameter carrying any
35
+ * property modifier declares a field and classifies exactly as the same field
36
+ * written in the class body would, whatever the constructor's own visibility
37
+ * is, so a `private` or `protected` one materializes nothing, exactly as the
38
+ * body form does not. The property modifiers are TypeScript's own five:
39
+ * `public`, `protected`, `private`, `readonly`, and `override`. The last is
40
+ * the one to know about, because its meaning is about the base class rather
41
+ * than about the field, so it does not read as a field declaration. It still
42
+ * declares one: on a class extending one that declares `rate`,
43
+ * `constructor(override rate: number)` is a public instance field. A
44
+ * parameter property's citation belongs on the parameter: a constructor's own
45
+ * block hosts nothing, because two parameter properties would leave
46
+ * `@evidence` no way to say which field it means.
26
47
  *
27
48
  * TypeScript units form containment scopes. An interface or object-shaped type
28
- * alias contains its selected properties. A namespace contains every selected
29
- * public unit nested below it, including nested namespaces. An `@evidence`
30
- * target, or an `@evidenceExclude` target allowed by its reference policy,
31
- * acknowledges its selected node and every selected descendant. A reference
32
- * selector defines the obligation kinds while their unselected type ancestors
33
- * remain addressable as aggregate scopes.
49
+ * alias contains the members it declares, callables included. A class contains
50
+ * its selected members. A namespace contains every selected public unit nested
51
+ * below it, including nested namespaces. An `@evidence` target, or an
52
+ * `@evidenceExclude` target allowed by its reference policy, acknowledges its
53
+ * selected node and every selected descendant. A reference selector defines the
54
+ * obligation kinds while their unselected type ancestors remain addressable as
55
+ * aggregate scopes.
34
56
  *
35
57
  * Top-level identities use the public export name, and namespace members
36
58
  * prepend their namespace, such as `Orders.create`. A namespace itself uses its
@@ -38,16 +60,29 @@
38
60
  * exposed as `export { Local as Public }` therefore uses `Public`. A named
39
61
  * default declaration keeps its declaration name; anonymous and default-only
40
62
  * aliases have no stable target and are not selected. Members of an ambient
41
- * namespace are public without their own `export` modifier. A type-only
42
- * namespace alias exposes the namespace, its public type-space descendants, and
43
- * their type properties without exposing data, functions, or class callables.
44
- * Type properties use `TypeName.property`. Static class callables use
45
- * `ClassName.method`; instance callables use `ClassName.prototype.method`.
46
- * Computed names are not selected, even when their expression is a literal.
47
- * Literal names must be whitespace-free because a declaration target is one
48
- * whitespace-delimited token. A dot inside a literal name is rendered
49
- * unchanged; if that spelling collides with qualification, the target is
50
- * ambiguous.
63
+ * namespace are public without their own `export` modifier. A type-only alias
64
+ * exposes a namespace or a class, its public type-space descendants, and
65
+ * everything an object-shaped type alias, or an interface no class merges with,
66
+ * declares, callables included, because every member of those two is
67
+ * type-space. What it withholds is value-space: namespace data, namespace
68
+ * functions, and every class member, the last because a member is addressed
69
+ * through the class value the alias does not expose. An interface merged with a
70
+ * class is withheld with it, since its members are reached through that same
71
+ * value. Every type-only export withholds, in every spelling and across every
72
+ * module boundary: an export list in the declaring file, an inline `export {
73
+ * type Sale }`, and a re-export in any of `export type { Sale } from`, `export
74
+ * { type Sale } from`, `export type * from`, and `export type * as api from`. A
75
+ * member of an object-shaped type alias, or of an interface no class merges
76
+ * with, uses `TypeName.member`, whether it carries data or is written as a
77
+ * callable; one declared by an interface merged with a class is an instance
78
+ * member of that class and uses `ClassName.prototype.member` like the body
79
+ * form. Static class members use `ClassName.member`; instance members use
80
+ * `ClassName.prototype.member`, and a constructor parameter property is an
81
+ * instance member addressed that way. Computed names are not selected, even
82
+ * when their expression is a literal. Literal names must be whitespace-free
83
+ * because a declaration target is one whitespace-delimited token. A dot inside
84
+ * a literal name is rendered unchanged; if that spelling collides with
85
+ * qualification, the target is ambiguous.
51
86
  *
52
87
  * These targets deliberately omit file paths. If selected files expose the same
53
88
  * qualified target, a declaration using that target is ambiguous; rename or
@@ -0,0 +1,162 @@
1
+ package evidence
2
+
3
+ // Parser-aware comment enumeration.
4
+ //
5
+ // This is a copy of the enumeration `@ttsc/lint` performs for its own rules,
6
+ // carried here rather than imported from it. `@ttsc/lint` is a peer dependency
7
+ // with a published range, and this package ships Go **source** into a
8
+ // consumer's build against whichever version they installed. A helper this file
9
+ // took from that package would compile here and fail there for every consumer
10
+ // on a release that predates it, which is what a benchmark fixture proved by
11
+ // refusing `undefined: astutil.ForEachComment`. What crosses that boundary is
12
+ // the published rule API and nothing else.
13
+
14
+ import (
15
+ "sort"
16
+
17
+ shimast "github.com/microsoft/typescript-go/shim/ast"
18
+ shimscanner "github.com/microsoft/typescript-go/shim/scanner"
19
+ )
20
+
21
+ type commentToken struct {
22
+ kind shimast.Kind
23
+ pos int
24
+ end int
25
+ }
26
+
27
+ type sourceSpan struct {
28
+ pos int
29
+ end int
30
+ }
31
+
32
+ // forEachCommentToken visits every real comment in `file` in source order.
33
+ //
34
+ // TypeScript's parser, rather than a context-free scanner, owns the lexical
35
+ // goal for regular expressions, templates, and JSX. The parsed AST retains
36
+ // those context-sensitive tokens as exact spans. We treat those spans as
37
+ // opaque and scan only the gaps between them, where slash-shaped bytes are
38
+ // ordinary JavaScript trivia or punctuation. This preserves the scanner's
39
+ // complete comment and line-terminator behavior without guessing whether a
40
+ // slash is division, manually tracking template braces, or special-casing JSX
41
+ // strings. Exact ranges are deduplicated before the callback runs because a
42
+ // recovery AST can expose overlapping token nodes for malformed source.
43
+ func forEachComment(file *shimast.SourceFile, visit func(kind shimast.Kind, pos, end int)) {
44
+ if file == nil || visit == nil {
45
+ return
46
+ }
47
+ text := file.Text()
48
+ opaque := parserOpaqueTokenSpans(file)
49
+ comments := make([]commentToken, 0)
50
+ seen := make(map[sourceSpan]struct{})
51
+ gapScanner := shimscanner.NewScanner()
52
+ collect := func(kind shimast.Kind, pos, end int) {
53
+ if pos < 0 || end <= pos || end > len(text) {
54
+ return
55
+ }
56
+ span := sourceSpan{pos: pos, end: end}
57
+ if _, ok := seen[span]; ok {
58
+ return
59
+ }
60
+ seen[span] = struct{}{}
61
+ comments = append(comments, commentToken{kind: kind, pos: pos, end: end})
62
+ }
63
+
64
+ cursor := 0
65
+ for _, span := range opaque {
66
+ if cursor < span.pos {
67
+ scanCommentGap(gapScanner, text, cursor, span.pos, collect)
68
+ }
69
+ if span.end > cursor {
70
+ cursor = span.end
71
+ }
72
+ }
73
+ if cursor < len(text) {
74
+ scanCommentGap(gapScanner, text, cursor, len(text), collect)
75
+ }
76
+
77
+ sort.Slice(comments, func(i, j int) bool {
78
+ if comments[i].pos != comments[j].pos {
79
+ return comments[i].pos < comments[j].pos
80
+ }
81
+ return comments[i].end < comments[j].end
82
+ })
83
+ for _, comment := range comments {
84
+ visit(comment.kind, comment.pos, comment.end)
85
+ }
86
+ }
87
+
88
+ // parserOpaqueTokenSpans returns merged source ranges for the parser-owned
89
+ // tokens whose contents a context-free scanner cannot classify: strings,
90
+ // regex literals, template heads/middles/tails, and JSX text. Numeric literals,
91
+ // identifiers, keywords, and punctuation remain in the scanned gaps because
92
+ // they cannot contain comment-shaped source bytes; keeping them there avoids a
93
+ // scanner reset for every ordinary token.
94
+ func parserOpaqueTokenSpans(file *shimast.SourceFile) []sourceSpan {
95
+ spans := make([]sourceSpan, 0)
96
+ var walk func(*shimast.Node)
97
+ walk = func(node *shimast.Node) {
98
+ if node == nil {
99
+ return
100
+ }
101
+ if parserOpaqueTokenKind(node.Kind) {
102
+ pos := shimscanner.GetTokenPosOfNode(node, file, false /*includeJSDoc*/)
103
+ if pos >= 0 && node.End() > pos && node.End() <= len(file.Text()) {
104
+ spans = append(spans, sourceSpan{pos: pos, end: node.End()})
105
+ }
106
+ return
107
+ }
108
+ node.ForEachChild(func(child *shimast.Node) bool {
109
+ walk(child)
110
+ return false
111
+ })
112
+ }
113
+ walk(file.AsNode())
114
+ sort.Slice(spans, func(i, j int) bool {
115
+ if spans[i].pos != spans[j].pos {
116
+ return spans[i].pos < spans[j].pos
117
+ }
118
+ return spans[i].end < spans[j].end
119
+ })
120
+ if len(spans) < 2 {
121
+ return spans
122
+ }
123
+ merged := spans[:1]
124
+ for _, span := range spans[1:] {
125
+ last := &merged[len(merged)-1]
126
+ if span.pos <= last.end {
127
+ if span.end > last.end {
128
+ last.end = span.end
129
+ }
130
+ continue
131
+ }
132
+ merged = append(merged, span)
133
+ }
134
+ return merged
135
+ }
136
+
137
+ func parserOpaqueTokenKind(kind shimast.Kind) bool {
138
+ return kind >= shimast.KindStringLiteral && kind <= shimast.KindLastLiteralToken ||
139
+ kind >= shimast.KindFirstTemplateToken && kind <= shimast.KindLastTemplateToken
140
+ }
141
+
142
+ // scanCommentGap scans one parser-classified non-token gap. Context-sensitive
143
+ // token bodies never enter this function, so every comment trivia token it
144
+ // returns is a real source comment. Scanning the isolated gap also preserves
145
+ // CRLF and Unicode line-terminator behavior without range arithmetic in a
146
+ // second comment parser.
147
+ func scanCommentGap(scanner *shimscanner.Scanner, text string, from, to int, visit func(kind shimast.Kind, pos, end int)) {
148
+ if scanner == nil || from < 0 || to <= from || to > len(text) {
149
+ return
150
+ }
151
+ scanner.SetText(text[from:to])
152
+ scanner.SetSkipTrivia(false)
153
+ for {
154
+ kind := scanner.Scan()
155
+ switch kind {
156
+ case shimast.KindEndOfFile:
157
+ return
158
+ case shimast.KindSingleLineCommentTrivia, shimast.KindMultiLineCommentTrivia:
159
+ visit(kind, from+scanner.TokenStart(), from+scanner.TokenEnd())
160
+ }
161
+ }
162
+ }
package/native/config.go CHANGED
@@ -185,6 +185,7 @@ func decodeReference(
185
185
  "noEvidenceExclude",
186
186
  "uniqueEvidence",
187
187
  "singleEvidencePerSymbol",
188
+ "requireReview",
188
189
  "package",
189
190
  "root",
190
191
  "file",
@@ -306,6 +307,7 @@ func decodeReferencePolicy(
306
307
  decodeFlag("noEvidenceExclude", &policy.NoExclude)
307
308
  decodeFlag("uniqueEvidence", &policy.UniqueEvidence)
308
309
  decodeFlag("singleEvidencePerSymbol", &policy.SingleEvidencePerSymbol)
310
+ decodeFlag("requireReview", &policy.RequireReview)
309
311
  return policy, problems
310
312
  }
311
313
 
@@ -102,6 +102,21 @@ func parseCommentDeclarations(
102
102
  }
103
103
  continue
104
104
  }
105
+ // A review closes the declaration above it in every host, and it does so
106
+ // regardless of tagBoundaries. That flag answers whether *another tool's*
107
+ // `@tag` is a boundary, which is a property of the host's comment grammar:
108
+ // an HTML comment has no field syntax, so `@architecture approved this` is
109
+ // ordinary prose belonging to the reason above it, and a test pins that.
110
+ //
111
+ // A review is not another tool's tag. It belongs to this grammar, so it is
112
+ // always a boundary. Without this, an `@evidenceReview` written under an
113
+ // `@evidence` inside one Markdown comment was swallowed into that citation's
114
+ // reason: the review vanished and the reason grew a sentence its author
115
+ // addressed to a different question.
116
+ if _, _, opened := reviewLine(line); opened {
117
+ flush()
118
+ continue
119
+ }
105
120
  if jsdoc && strings.HasPrefix(line, "@") {
106
121
  flush()
107
122
  continue
@@ -220,6 +235,204 @@ func normalizeMarkdownTarget(target string) string {
220
235
  return target
221
236
  }
222
237
 
238
+ // reviewMarkers open a verification statement, one per acknowledgement tag.
239
+ //
240
+ // There are two because the two acknowledgements answer opposite questions and
241
+ // so do their reviews. Verifying an `@evidence` means checking that this
242
+ // declaration does what the cited unit describes. Verifying an
243
+ // `@evidenceExclude` means checking that the unit genuinely does not apply here,
244
+ // which no amount of reading the code can establish and no coverage number can.
245
+ // One tag for both would leave a reader unable to tell which question was
246
+ // answered without finding the sibling tag first, and would let a review of the
247
+ // easier question discharge the harder one.
248
+ //
249
+ // Order is longest marker first, the way `declarationLine` orders its own, so a
250
+ // prefix never shadows a longer tag. The whitespace boundary keeps the four
251
+ // apart on its own — `@evidenceExcludeReview` reaching the `@evidenceExclude`
252
+ // arm is refused exactly as `@evidenceReview` reaching `@evidence` is — but the
253
+ // ordering means that guard is a second line of defense rather than the only one.
254
+ var reviewMarkers = []struct {
255
+ marker string
256
+ tag tagKind
257
+ }{
258
+ {marker: "@evidenceExcludeReview", tag: tagExclude},
259
+ {marker: "@evidenceReview", tag: tagEvidence},
260
+ }
261
+
262
+ // reviewFingerprintLength is how many hex characters a fingerprint presents.
263
+ //
264
+ // Seven is a staleness detector's length, not a security boundary's. A
265
+ // collision costs one review that failed to expire, while the tag stays short
266
+ // enough that an author reads the target beside it instead of scrolling past a
267
+ // full digest.
268
+ const reviewFingerprintLength = 7
269
+
270
+ // parsedReview is one verification statement read out of a comment.
271
+ //
272
+ // It is deliberately not a `parsedDeclaration`, and the separation is the whole
273
+ // safety property rather than a matter of taste. Every acknowledgement map in
274
+ // `evaluateEvidenceGraph` consumes the declarations of a claim, so a review
275
+ // arriving as a third `tagKind` would discharge coverage, count as a semantic
276
+ // host under `uniqueEvidence`, count as a cited unit under
277
+ // `singleEvidencePerSymbol`, and conflict with an exclusion of the same scope.
278
+ // Each of those is a build turning green because a review was mistaken for
279
+ // evidence. A distinct type cannot reach any of them by construction; a shared
280
+ // type could only be kept out by remembering to, at every one of six sites.
281
+ type parsedReview struct {
282
+ // Reviews names which acknowledgement this review answers for, so a review of
283
+ // an exclusion can never discharge a citation's obligation or the reverse.
284
+ Reviews tagKind
285
+ Target string
286
+ Fingerprint string
287
+ Description string
288
+ LineOffset int
289
+ }
290
+
291
+ // marker spells the tag this review was written as, for a diagnostic that has to
292
+ // name the repair rather than describe it.
293
+ func (review parsedReview) marker() string {
294
+ return reviewMarkerFor(review.Reviews)
295
+ }
296
+
297
+ // parseReviews reads every verification statement one comment carries.
298
+ //
299
+ // `declarationLine` needs no change to keep these out of the declaration
300
+ // stream, and that is worth stating because it looks like an omission. It
301
+ // matches `@evidence` only when the next character is whitespace, so
302
+ // `@evidenceReview` falls through its loop exactly as `@evidenceExclude`
303
+ // reaching the `@evidence` arm does. The marker namespace was already reserved.
304
+ //
305
+ // The scan is independent for the same reason `todoEntries` is: one tag, one
306
+ // pass, no shared state with the parser whose output must never contain it. Any
307
+ // other `@`-opening line closes the review above it, so a review sharing a
308
+ // block with `@param` or a following `@evidence` swallows neither.
309
+ func parseReviews(comment string) []parsedReview {
310
+ trimmed := strings.TrimLeftFunc(comment, unicode.IsSpace)
311
+ leadingLines := strings.Count(comment[:len(comment)-len(trimmed)], "\n")
312
+ comment = trimmed
313
+ comment = strings.TrimPrefix(comment, "/**")
314
+ comment = strings.TrimPrefix(comment, "/*")
315
+ comment = strings.TrimSuffix(comment, "*/")
316
+ reviews := []parsedReview{}
317
+ var pending *parsedReview
318
+ var body []string
319
+ flush := func() {
320
+ if pending == nil {
321
+ return
322
+ }
323
+ target, remainder := splitDeclarationBody(strings.Join(body, "\n"))
324
+ fingerprint, description := splitReviewFingerprint(remainder)
325
+ pending.Target = target
326
+ pending.Fingerprint = fingerprint
327
+ pending.Description = description
328
+ reviews = append(reviews, *pending)
329
+ pending = nil
330
+ body = nil
331
+ }
332
+ for index, rawLine := range strings.Split(comment, "\n") {
333
+ line := strings.TrimSpace(rawLine)
334
+ line = strings.TrimSpace(strings.TrimPrefix(line, "*"))
335
+ line = strings.TrimSpace(strings.TrimPrefix(line, "///"))
336
+ if reviews, remainder, opened := reviewLine(line); opened {
337
+ flush()
338
+ pending = &parsedReview{
339
+ Reviews: reviews,
340
+ LineOffset: leadingLines + index,
341
+ }
342
+ body = []string{remainder}
343
+ continue
344
+ }
345
+ if strings.HasPrefix(line, "@") {
346
+ flush()
347
+ continue
348
+ }
349
+ if pending != nil {
350
+ body = append(body, line)
351
+ }
352
+ }
353
+ flush()
354
+ return reviews
355
+ }
356
+
357
+ // reviewLine reports whether a line opens a review, which acknowledgement it
358
+ // answers for, and with what remainder.
359
+ //
360
+ // The whitespace boundary keeps a longer tag out. `@evidenceReviewed` is some
361
+ // other tool's tag or a typo, and consuming it would attach a review to a
362
+ // citation the author never reviewed. The same boundary is what separates
363
+ // `@evidenceExcludeReview` from `@evidenceExclude` in `declarationLine`, so
364
+ // neither parser can claim the other's tag.
365
+ func reviewLine(line string) (tagKind, string, bool) {
366
+ for _, candidate := range reviewMarkers {
367
+ if !strings.HasPrefix(line, candidate.marker) {
368
+ continue
369
+ }
370
+ remainder := line[len(candidate.marker):]
371
+ if remainder != "" && remainder[0] != ' ' && remainder[0] != '\t' {
372
+ continue
373
+ }
374
+ return candidate.tag, strings.TrimSpace(remainder), true
375
+ }
376
+ return "", "", false
377
+ }
378
+
379
+ // splitReviewFingerprint separates an optional fingerprint from the description.
380
+ //
381
+ // The `#` prefix is required rather than inferred, for the reason
382
+ // `splitInlineLinkBody` records about targets: the token stays
383
+ // self-discriminating through a boundary character instead of a guess. A bare
384
+ // fixed-width hex token would reintroduce that guess, and ordinary prose
385
+ // supplies the counter-examples — `cafe`, `deadbeef`, `beefed`.
386
+ //
387
+ // The exact length is checked as well as the prefix, because `#` alone is not
388
+ // enough. A requirement anchor such as `#req-search-policies` opens a
389
+ // description in exactly that shape, and eating it would strip the author's
390
+ // first words and then report a malformed fingerprint they did not write.
391
+ //
392
+ // No target begins with `#`: a Markdown target carries its anchor after a path,
393
+ // a Prisma target carries its `prisma:` prefix, a Swagger target its method,
394
+ // and a code target its brace. So this only ever reads the position after a
395
+ // target that has already been consumed.
396
+ func splitReviewFingerprint(remainder string) (string, string) {
397
+ remainder = strings.TrimSpace(remainder)
398
+ if !strings.HasPrefix(remainder, "#") {
399
+ return "", remainder
400
+ }
401
+ candidate := remainder[1:]
402
+ description := ""
403
+ for index, char := range candidate {
404
+ if unicode.IsSpace(char) {
405
+ candidate, description = candidate[:index], strings.TrimSpace(candidate[index:])
406
+ break
407
+ }
408
+ }
409
+ if len(candidate) != reviewFingerprintLength || !isLowerHex(candidate) {
410
+ // A rejected token stays in the description, because it is usually prose: a
411
+ // requirement anchor such as `#req-search-policies` opens a sentence in
412
+ // exactly this shape and the author's words must survive.
413
+ //
414
+ // Unless it is the whole body. `#A3F9C1D` alone is a fingerprint whose case
415
+ // is wrong, not a description, and treating it as prose would let the
416
+ // shortest wrong path an author can take pass silently: paste the expected
417
+ // value, get the case wrong, stop, and ship a review that states nothing
418
+ // while satisfying the non-empty test. Reported as malformed instead.
419
+ if description == "" {
420
+ return "", ""
421
+ }
422
+ return "", remainder
423
+ }
424
+ return candidate, description
425
+ }
426
+
427
+ func isLowerHex(value string) bool {
428
+ for _, char := range value {
429
+ if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
430
+ return false
431
+ }
432
+ }
433
+ return value != ""
434
+ }
435
+
223
436
  func containsWhitespace(value string) bool {
224
437
  for _, char := range value {
225
438
  if unicode.IsSpace(char) {
@@ -251,17 +251,17 @@ func documentedHosts(file *shimast.SourceFile) []documentedHost {
251
251
 
252
252
  // hostNodesOf keeps the declarations that can actually carry a citation.
253
253
  //
254
- // A name can be spelled by a declaration that hosts nothing: a class is not a
255
- // type unit, so `class C` beside `namespace C` materializes the `C` type from
256
- // the namespace alone, and the collector registers no host for the class at
257
- // all. Demanding the block there would send an author's `@evidence` into a
258
- // position where `evidence/graph` rejects it as an unsupported host — the rule
259
- // steering citations somewhere a citation cannot live, which is the failure it
260
- // exists to prevent.
254
+ // A unit's nodes are the positions the identity owns, and owning a position is
255
+ // not the same as being able to hold a tag there. A withdrawn identity records
256
+ // declarations that are deliberately not hosts, and the caller has skipped
257
+ // those before asking, so every position reaching this today is one. That makes
258
+ // the filter remove nothing, which is the point: it states the premise the rule
259
+ // depends on rather than waiting for a form that breaks it.
261
260
  //
262
- // Narrowing to real hosts also keeps this rule's premise exact. The population
263
- // that must be able to hold a tag is the population a claim can select as a
264
- // host, and nothing wider.
261
+ // Demanding a block at a position that is not a host would send an author's
262
+ // `@evidence` where `evidence/graph` rejects it as an unsupported host, the
263
+ // rule steering citations somewhere a citation cannot live, which is the
264
+ // failure it exists to prevent.
265
265
  func hostNodesOf(
266
266
  nodes []*shimast.Node,
267
267
  supported map[*shimast.Node]symbolSet,
@@ -280,8 +280,8 @@ func hostNodesOf(
280
280
  //
281
281
  // The collector records them as it walks, and the merged forms are folded in
282
282
  // afterwards, so the slice arrives in neither order. Placement is defined
283
- // against the declaration that comes first, so that has to be `Node` — and for
284
- // a variable the enclosing statement wins over its binding, which is where
283
+ // against the declaration that comes first, so that has to be `Node`, and for a
284
+ // variable the enclosing statement wins over its declarator, which is where
285
285
  // TypeScript actually attaches the block.
286
286
  func orderIdentityDeclarations(hosts []documentedHost) []documentedHost {
287
287
  for index := range hosts {